From 0a5c65e29c8aeb3642d1d16066ace2f308ec574a Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Tue, 6 Dec 2022 13:27:26 +0100 Subject: [PATCH] Add REST implementation for Validator's `ValidatorIndex` (#11712) * Add GetAttestationData * Add tests * Add many more tests and refactor * Fix logic * Address PR comments * Address PR comments * Add jsonRestHandler and decouple http logic from rest of the code * Add buildURL tests * Remove handlers_test.go * Improve tests * Implement `ValidatorIndex` of `beaconApiValidatorClient` using Beacon API * Implement getStateValidators * `validatorIndex`: Use `getStateValidators` Co-authored-by: Patrice Vignola --- hack/update-mockgen.sh | 1 + validator/client/beacon-api/BUILD.bazel | 11 +- .../client/beacon-api/attestation_data.go | 107 +++++++ .../beacon-api/attestation_data_test.go | 243 +++++++++++++++ .../client/beacon-api/beacon_api_helpers.go | 10 + .../beacon-api/beacon_api_helpers_test.go | 18 ++ .../beacon-api/beacon_api_validator_client.go | 35 +-- .../beacon_api_validator_client_test.go | 77 ++++- validator/client/beacon-api/genesis.go | 29 +- validator/client/beacon-api/genesis_test.go | 130 ++++----- validator/client/beacon-api/handlers_test.go | 96 ------ validator/client/beacon-api/index.go | 35 +++ validator/client/beacon-api/index_test.go | 181 ++++++++++++ .../client/beacon-api/json_rest_handler.go | 55 ++++ .../beacon-api/json_rest_handler_test.go | 192 ++++++++++++ validator/client/beacon-api/mock/BUILD.bazel | 8 +- .../beacon-api/mock/json_rest_handler_mock.go | 50 ++++ .../client/beacon-api/state_validators.go | 37 +++ .../beacon-api/state_validators_test.go | 133 +++++++++ .../beacon-api/wait_for_chain_start_test.go | 276 +++++++++--------- 20 files changed, 1380 insertions(+), 344 deletions(-) create mode 100644 validator/client/beacon-api/attestation_data.go create mode 100644 validator/client/beacon-api/attestation_data_test.go delete mode 100644 validator/client/beacon-api/handlers_test.go create mode 100644 validator/client/beacon-api/index.go create mode 100644 validator/client/beacon-api/index_test.go create mode 100644 validator/client/beacon-api/json_rest_handler.go create mode 100644 validator/client/beacon-api/json_rest_handler_test.go create mode 100644 validator/client/beacon-api/mock/json_rest_handler_mock.go create mode 100644 validator/client/beacon-api/state_validators.go create mode 100644 validator/client/beacon-api/state_validators_test.go diff --git a/hack/update-mockgen.sh b/hack/update-mockgen.sh index fbe35eaf6..97a394e71 100755 --- a/hack/update-mockgen.sh +++ b/hack/update-mockgen.sh @@ -73,6 +73,7 @@ gofmt -s -w "$mock_path/." beacon_api_mock_path="validator/client/beacon-api/mock" beacon_api_mocks=( "$beacon_api_mock_path/genesis_mock.go genesis.go" + "$beacon_api_mock_path/json_rest_handler_mock.go json_rest_handler.go" ) for ((i = 0; i < ${#beacon_api_mocks[@]}; i++)); do diff --git a/validator/client/beacon-api/BUILD.bazel b/validator/client/beacon-api/BUILD.bazel index 074a78a8c..00c4e45da 100644 --- a/validator/client/beacon-api/BUILD.bazel +++ b/validator/client/beacon-api/BUILD.bazel @@ -4,10 +4,14 @@ load("@prysm//tools/go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ + "attestation_data.go", "beacon_api_helpers.go", "beacon_api_validator_client.go", "domain_data.go", "genesis.go", + "index.go", + "json_rest_handler.go", + "state_validators.go", ], importpath = "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api", visibility = ["//validator:__subpackages__"], @@ -31,11 +35,14 @@ go_test( name = "go_default_test", size = "small", srcs = [ + "attestation_data_test.go", "beacon_api_helpers_test.go", "beacon_api_validator_client_test.go", "domain_data_test.go", "genesis_test.go", - "handlers_test.go", + "index_test.go", + "json_rest_handler_test.go", + "state_validators_test.go", "wait_for_chain_start_test.go", ], embed = [":go_default_library"], @@ -44,6 +51,7 @@ go_test( "//api/gateway/apimiddleware:go_default_library", "//beacon-chain/rpc/apimiddleware:go_default_library", "//config/params:go_default_library", + "//consensus-types/primitives:go_default_library", "//encoding/bytesutil:go_default_library", "//proto/prysm/v1alpha1:go_default_library", "//testing/assert:go_default_library", @@ -51,6 +59,7 @@ go_test( "//validator/client/beacon-api/mock:go_default_library", "@com_github_ethereum_go_ethereum//common/hexutil:go_default_library", "@com_github_golang_mock//gomock:go_default_library", + "@com_github_pkg_errors//:go_default_library", "@org_golang_google_protobuf//types/known/emptypb:go_default_library", ], ) diff --git a/validator/client/beacon-api/attestation_data.go b/validator/client/beacon-api/attestation_data.go new file mode 100644 index 000000000..26cfa252e --- /dev/null +++ b/validator/client/beacon-api/attestation_data.go @@ -0,0 +1,107 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "net/url" + "strconv" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/pkg/errors" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + types "github.com/prysmaticlabs/prysm/v3/consensus-types/primitives" + ethpb "github.com/prysmaticlabs/prysm/v3/proto/prysm/v1alpha1" +) + +func (c beaconApiValidatorClient) getAttestationData( + reqSlot types.Slot, + reqCommitteeIndex types.CommitteeIndex, +) (*ethpb.AttestationData, error) { + params := url.Values{} + params.Add("slot", strconv.FormatUint(uint64(reqSlot), 10)) + params.Add("committee_index", strconv.FormatUint(uint64(reqCommitteeIndex), 10)) + + query := buildURL("/eth/v1/validator/attestation_data", params) + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + _, err := c.jsonRestHandler.GetRestJsonResponse(query, &produceAttestationDataResponseJson) + if err != nil { + return nil, errors.Wrap(err, "failed to get json response") + } + + if produceAttestationDataResponseJson.Data == nil { + return nil, errors.New("attestation data is nil") + } + + attestationData := produceAttestationDataResponseJson.Data + committeeIndex, err := strconv.ParseUint(attestationData.CommitteeIndex, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse attestation committee index: %s", attestationData.CommitteeIndex) + } + + if !validRoot(attestationData.BeaconBlockRoot) { + return nil, errors.Errorf("invalid beacon block root: %s", attestationData.BeaconBlockRoot) + } + + beaconBlockRoot, err := hexutil.Decode(attestationData.BeaconBlockRoot) + if err != nil { + return nil, errors.Wrapf(err, "failed to decode beacon block root: %s", attestationData.BeaconBlockRoot) + } + + slot, err := strconv.ParseUint(attestationData.Slot, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse attestation slot: %s", attestationData.Slot) + } + + if attestationData.Source == nil { + return nil, errors.New("attestation source is nil") + } + + sourceEpoch, err := strconv.ParseUint(attestationData.Source.Epoch, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse attestation source epoch: %s", attestationData.Source.Epoch) + } + + if !validRoot(attestationData.Source.Root) { + return nil, errors.Errorf("invalid attestation source root: %s", attestationData.Source.Root) + } + + sourceRoot, err := hexutil.Decode(attestationData.Source.Root) + if err != nil { + return nil, errors.Wrapf(err, "failed to decode attestation source root: %s", attestationData.Source.Root) + } + + if attestationData.Target == nil { + return nil, errors.New("attestation target is nil") + } + + targetEpoch, err := strconv.ParseUint(attestationData.Target.Epoch, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse attestation target epoch: %s", attestationData.Target.Epoch) + } + + if !validRoot(attestationData.Target.Root) { + return nil, errors.Errorf("invalid attestation target root: %s", attestationData.Target.Root) + } + + targetRoot, err := hexutil.Decode(attestationData.Target.Root) + if err != nil { + return nil, errors.Wrapf(err, "failed to decode attestation target root: %s", attestationData.Target.Root) + } + + response := ðpb.AttestationData{ + BeaconBlockRoot: beaconBlockRoot, + CommitteeIndex: types.CommitteeIndex(committeeIndex), + Slot: types.Slot(slot), + Source: ðpb.Checkpoint{ + Epoch: types.Epoch(sourceEpoch), + Root: sourceRoot, + }, + Target: ðpb.Checkpoint{ + Epoch: types.Epoch(targetEpoch), + Root: targetRoot, + }, + } + + return response, nil +} diff --git a/validator/client/beacon-api/attestation_data_test.go b/validator/client/beacon-api/attestation_data_test.go new file mode 100644 index 000000000..285d47907 --- /dev/null +++ b/validator/client/beacon-api/attestation_data_test.go @@ -0,0 +1,243 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "errors" + "fmt" + "strconv" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/golang/mock/gomock" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + types "github.com/prysmaticlabs/prysm/v3/consensus-types/primitives" + "github.com/prysmaticlabs/prysm/v3/testing/assert" + "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" +) + +const attestationDataEndpoint = "/eth/v1/validator/attestation_data" + +func TestGetAttestationData_ValidAttestation(t *testing.T) { + expectedSlot := uint64(5) + expectedCommitteeIndex := uint64(6) + expectedBeaconBlockRoot := "0x0636045df9bdda3ab96592cf5389032c8ec3977f911e2b53509b348dfe164d4d" + expectedSourceEpoch := uint64(7) + expectedSourceRoot := "0xd4bcbdefc8156e85247681086e8050e5d2d5d1bf076a25f6decd99250f3a378d" + expectedTargetEpoch := uint64(8) + expectedTargetRoot := "0x246590e8e4c2a9bd13cc776ecc7025bc432219f076e80b27267b8fa0456dc821" + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + + jsonRestHandler.EXPECT().GetRestJsonResponse( + fmt.Sprintf("/eth/v1/validator/attestation_data?committee_index=%d&slot=%d", expectedCommitteeIndex, expectedSlot), + &produceAttestationDataResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.ProduceAttestationDataResponseJson{ + Data: &rpcmiddleware.AttestationDataJson{ + Slot: strconv.FormatUint(expectedSlot, 10), + CommitteeIndex: strconv.FormatUint(expectedCommitteeIndex, 10), + BeaconBlockRoot: expectedBeaconBlockRoot, + Source: &rpcmiddleware.CheckpointJson{ + Epoch: strconv.FormatUint(expectedSourceEpoch, 10), + Root: expectedSourceRoot, + }, + Target: &rpcmiddleware.CheckpointJson{ + Epoch: strconv.FormatUint(expectedTargetEpoch, 10), + Root: expectedTargetRoot, + }, + }, + }, + ).Times(1) + + validatorClient := &beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + resp, err := validatorClient.getAttestationData(types.Slot(expectedSlot), types.CommitteeIndex(expectedCommitteeIndex)) + assert.NoError(t, err) + + require.NotNil(t, resp) + assert.Equal(t, expectedBeaconBlockRoot, hexutil.Encode(resp.BeaconBlockRoot)) + assert.Equal(t, expectedCommitteeIndex, uint64(resp.CommitteeIndex)) + assert.Equal(t, expectedSlot, uint64(resp.Slot)) + + require.NotNil(t, resp.Source) + assert.Equal(t, expectedSourceEpoch, uint64(resp.Source.Epoch)) + assert.Equal(t, expectedSourceRoot, hexutil.Encode(resp.Source.Root)) + + require.NotNil(t, resp.Target) + assert.Equal(t, expectedTargetEpoch, uint64(resp.Target.Epoch)) + assert.Equal(t, expectedTargetRoot, hexutil.Encode(resp.Target.Root)) +} + +func TestGetAttestationData_InvalidData(t *testing.T) { + testCases := []struct { + name string + generateData func() rpcmiddleware.ProduceAttestationDataResponseJson + expectedErrorMessage string + }{ + { + name: "nil attestation data", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + return rpcmiddleware.ProduceAttestationDataResponseJson{ + Data: nil, + } + }, + expectedErrorMessage: "attestation data is nil", + }, + { + name: "invalid committee index", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.CommitteeIndex = "foo" + return attestation + }, + expectedErrorMessage: "failed to parse attestation committee index: foo", + }, + { + name: "invalid block root", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.BeaconBlockRoot = "foo" + return attestation + }, + expectedErrorMessage: "invalid beacon block root: foo", + }, + { + name: "invalid slot", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Slot = "foo" + return attestation + }, + expectedErrorMessage: "failed to parse attestation slot: foo", + }, + { + name: "nil source", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Source = nil + return attestation + }, + expectedErrorMessage: "attestation source is nil", + }, + { + name: "invalid source epoch", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Source.Epoch = "foo" + return attestation + }, + expectedErrorMessage: "failed to parse attestation source epoch: foo", + }, + { + name: "invalid source root", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Source.Root = "foo" + return attestation + }, + expectedErrorMessage: "invalid attestation source root: foo", + }, + { + name: "nil target", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Target = nil + return attestation + }, + expectedErrorMessage: "attestation target is nil", + }, + { + name: "invalid target epoch", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Target.Epoch = "foo" + return attestation + }, + expectedErrorMessage: "failed to parse attestation target epoch: foo", + }, + { + name: "invalid target root", + generateData: func() rpcmiddleware.ProduceAttestationDataResponseJson { + attestation := generateValidAttestation(1, 2) + attestation.Data.Target.Root = "foo" + return attestation + }, + expectedErrorMessage: "invalid attestation target root: foo", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/validator/attestation_data?committee_index=2&slot=1", + &produceAttestationDataResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + testCase.generateData(), + ).Times(1) + + validatorClient := &beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + _, err := validatorClient.getAttestationData(1, 2) + assert.ErrorContains(t, testCase.expectedErrorMessage, err) + }) + } +} + +func TestGetAttestationData_JsonResponseError(t *testing.T) { + const slot = types.Slot(1) + const committeeIndex = types.CommitteeIndex(2) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + jsonRestHandler.EXPECT().GetRestJsonResponse( + fmt.Sprintf("/eth/v1/validator/attestation_data?committee_index=%d&slot=%d", committeeIndex, slot), + &produceAttestationDataResponseJson, + ).Return( + nil, + errors.New("some specific json response error"), + ).Times(1) + + validatorClient := &beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + _, err := validatorClient.getAttestationData(slot, committeeIndex) + assert.ErrorContains(t, "failed to get json response", err) + assert.ErrorContains(t, "some specific json response error", err) +} + +func generateValidAttestation(slot uint64, committeeIndex uint64) rpcmiddleware.ProduceAttestationDataResponseJson { + return rpcmiddleware.ProduceAttestationDataResponseJson{ + Data: &rpcmiddleware.AttestationDataJson{ + Slot: strconv.FormatUint(slot, 10), + CommitteeIndex: strconv.FormatUint(committeeIndex, 10), + BeaconBlockRoot: "0x5ecf3bff35e39d5f75476d42950d549f81fa93038c46b6652ae89ae1f7ad834f", + Source: &rpcmiddleware.CheckpointJson{ + Epoch: "3", + Root: "0x9023c9e64f23c1d451d5073c641f5f69597c2ad7d82f6f16e67d703e0ce5db8b", + }, + Target: &rpcmiddleware.CheckpointJson{ + Epoch: "4", + Root: "0xb154d46803b15b458ca822466547b054bc124338c6ee1d9c433dcde8c4457cca", + }, + }, + } +} diff --git a/validator/client/beacon-api/beacon_api_helpers.go b/validator/client/beacon-api/beacon_api_helpers.go index 8df625eb4..0d09bbfa0 100644 --- a/validator/client/beacon-api/beacon_api_helpers.go +++ b/validator/client/beacon-api/beacon_api_helpers.go @@ -4,6 +4,8 @@ package beacon_api import ( + "fmt" + neturl "net/url" "regexp" ) @@ -14,3 +16,11 @@ func validRoot(root string) bool { } return matchesRegex } + +func buildURL(path string, queryParams ...neturl.Values) string { + if len(queryParams) == 0 { + return path + } + + return fmt.Sprintf("%s?%s", path, queryParams[0].Encode()) +} diff --git a/validator/client/beacon-api/beacon_api_helpers_test.go b/validator/client/beacon-api/beacon_api_helpers_test.go index f684ae9b9..d687ae4b7 100644 --- a/validator/client/beacon-api/beacon_api_helpers_test.go +++ b/validator/client/beacon-api/beacon_api_helpers_test.go @@ -4,6 +4,7 @@ package beacon_api import ( + "net/url" "testing" "github.com/prysmaticlabs/prysm/v3/testing/assert" @@ -53,3 +54,20 @@ func TestBeaconApiHelpers(t *testing.T) { }) } } + +func TestBuildURL_NoParams(t *testing.T) { + wanted := "/aaa/bbb/ccc" + actual := buildURL("/aaa/bbb/ccc") + assert.Equal(t, wanted, actual) +} + +func TestBuildURL_WithParams(t *testing.T) { + params := url.Values{} + params.Add("xxxx", "1") + params.Add("yyyy", "2") + params.Add("zzzz", "3") + + wanted := "/aaa/bbb/ccc?xxxx=1&yyyy=2&zzzz=3" + actual := buildURL("/aaa/bbb/ccc", params) + assert.Equal(t, wanted, actual) +} diff --git a/validator/client/beacon-api/beacon_api_validator_client.go b/validator/client/beacon-api/beacon_api_validator_client.go index b89dace9f..128a78674 100644 --- a/validator/client/beacon-api/beacon_api_validator_client.go +++ b/validator/client/beacon-api/beacon_api_validator_client.go @@ -17,19 +17,20 @@ import ( ) type beaconApiValidatorClient struct { - url string - httpClient http.Client - fallbackClient iface.ValidatorClient genesisProvider genesisProvider + jsonRestHandler jsonRestHandler + fallbackClient iface.ValidatorClient } -func NewBeaconApiValidatorClient(url string, timeout time.Duration) *beaconApiValidatorClient { - httpClient := http.Client{Timeout: timeout} +func NewBeaconApiValidatorClient(host string, timeout time.Duration) *beaconApiValidatorClient { + jsonRestHandler := beaconApiJsonRestHandler{ + httpClient: http.Client{Timeout: timeout}, + host: host, + } return &beaconApiValidatorClient{ - url: url, - httpClient: httpClient, - genesisProvider: beaconApiGenesisProvider{httpClient: httpClient, url: url}, + genesisProvider: beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler}, + jsonRestHandler: jsonRestHandler, } } @@ -66,13 +67,12 @@ func (c *beaconApiValidatorClient) DomainData(_ context.Context, in *ethpb.Domai return c.getDomainData(in.Epoch, domainType) } -func (c *beaconApiValidatorClient) GetAttestationData(ctx context.Context, in *ethpb.AttestationDataRequest) (*ethpb.AttestationData, error) { - if c.fallbackClient != nil { - return c.fallbackClient.GetAttestationData(ctx, in) +func (c *beaconApiValidatorClient) GetAttestationData(_ context.Context, in *ethpb.AttestationDataRequest) (*ethpb.AttestationData, error) { + if in == nil { + return nil, errors.New("GetAttestationData received nil argument `in`") } - // TODO: Implement me - panic("beaconApiValidatorClient.GetAttestationData is not implemented. To use a fallback client, create this validator with NewBeaconApiValidatorClientWithFallback instead.") + return c.getAttestationData(in.Slot, in.CommitteeIndex) } func (c *beaconApiValidatorClient) GetBeaconBlock(ctx context.Context, in *ethpb.BlockRequest) (*ethpb.GenericBeaconBlock, error) { @@ -237,13 +237,8 @@ func (c *beaconApiValidatorClient) SubscribeCommitteeSubnets(ctx context.Context panic("beaconApiValidatorClient.SubscribeCommitteeSubnets is not implemented. To use a fallback client, create this validator with NewBeaconApiValidatorClientWithFallback instead.") } -func (c *beaconApiValidatorClient) ValidatorIndex(ctx context.Context, in *ethpb.ValidatorIndexRequest) (*ethpb.ValidatorIndexResponse, error) { - if c.fallbackClient != nil { - return c.fallbackClient.ValidatorIndex(ctx, in) - } - - // TODO: Implement me - panic("beaconApiValidatorClient.ValidatorIndex is not implemented. To use a fallback client, create this validator with NewBeaconApiValidatorClientWithFallback instead.") +func (c *beaconApiValidatorClient) ValidatorIndex(_ context.Context, in *ethpb.ValidatorIndexRequest) (*ethpb.ValidatorIndexResponse, error) { + return c.validatorIndex(in) } func (c *beaconApiValidatorClient) ValidatorStatus(ctx context.Context, in *ethpb.ValidatorStatusRequest) (*ethpb.ValidatorStatusResponse, error) { diff --git a/validator/client/beacon-api/beacon_api_validator_client_test.go b/validator/client/beacon-api/beacon_api_validator_client_test.go index f8aacf283..8d3bf94ff 100644 --- a/validator/client/beacon-api/beacon_api_validator_client_test.go +++ b/validator/client/beacon-api/beacon_api_validator_client_test.go @@ -5,12 +5,15 @@ package beacon_api import ( "context" + "errors" "fmt" "testing" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/golang/mock/gomock" rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + types "github.com/prysmaticlabs/prysm/v3/consensus-types/primitives" + + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/prysmaticlabs/prysm/v3/config/params" "github.com/prysmaticlabs/prysm/v3/encoding/bytesutil" ethpb "github.com/prysmaticlabs/prysm/v3/proto/prysm/v1alpha1" @@ -18,7 +21,45 @@ import ( "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" ) -// Check that the DomainData() returns whatever the internal getDomainData() returns +func TestBeaconApiValidatorClient_GetAttestationDataNilInput(t *testing.T) { + validatorClient := beaconApiValidatorClient{} + _, err := validatorClient.GetAttestationData(context.Background(), nil) + assert.ErrorContains(t, "GetAttestationData received nil argument `in`", err) +} + +// Make sure that GetAttestationData() returns the same thing as the internal getAttestationData() +func TestBeaconApiValidatorClient_GetAttestationDataValid(t *testing.T) { + const slot = types.Slot(1) + const committeeIndex = types.CommitteeIndex(2) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + jsonRestHandler.EXPECT().GetRestJsonResponse( + fmt.Sprintf("/eth/v1/validator/attestation_data?committee_index=%d&slot=%d", committeeIndex, slot), + &produceAttestationDataResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + generateValidAttestation(uint64(slot), uint64(committeeIndex)), + ).Times(2) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + expectedResp, expectedErr := validatorClient.getAttestationData(slot, committeeIndex) + + resp, err := validatorClient.GetAttestationData( + context.Background(), + ðpb.AttestationDataRequest{Slot: slot, CommitteeIndex: committeeIndex}, + ) + + assert.DeepEqual(t, expectedErr, err) + assert.DeepEqual(t, expectedResp, resp) +} + func TestBeaconApiValidatorClient_DomainDataValid(t *testing.T) { const genesisValidatorRoot = "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2" epoch := params.BeaconConfig().AltairForkEpoch @@ -50,3 +91,35 @@ func TestBeaconApiValidatorClient_DomainDataError(t *testing.T) { _, err := validatorClient.DomainData(context.Background(), ðpb.DomainRequest{Epoch: epoch, Domain: domainType}) assert.ErrorContains(t, fmt.Sprintf("invalid domain type: %s", hexutil.Encode(domainType)), err) } + +func TestBeaconApiValidatorClient_GetAttestationDataError(t *testing.T) { + const slot = types.Slot(1) + const committeeIndex = types.CommitteeIndex(2) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + produceAttestationDataResponseJson := rpcmiddleware.ProduceAttestationDataResponseJson{} + jsonRestHandler.EXPECT().GetRestJsonResponse( + fmt.Sprintf("/eth/v1/validator/attestation_data?committee_index=%d&slot=%d", committeeIndex, slot), + &produceAttestationDataResponseJson, + ).Return( + nil, + errors.New("some specific json error"), + ).SetArg( + 1, + generateValidAttestation(uint64(slot), uint64(committeeIndex)), + ).Times(2) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + expectedResp, expectedErr := validatorClient.getAttestationData(slot, committeeIndex) + + resp, err := validatorClient.GetAttestationData( + context.Background(), + ðpb.AttestationDataRequest{Slot: slot, CommitteeIndex: committeeIndex}, + ) + + assert.ErrorContains(t, expectedErr.Error(), err) + assert.DeepEqual(t, expectedResp, resp) +} diff --git a/validator/client/beacon-api/genesis.go b/validator/client/beacon-api/genesis.go index 0b094da90..e2fc1c06e 100644 --- a/validator/client/beacon-api/genesis.go +++ b/validator/client/beacon-api/genesis.go @@ -5,7 +5,6 @@ package beacon_api import ( "context" - "encoding/json" "net/http" "strconv" "time" @@ -22,8 +21,7 @@ type genesisProvider interface { } type beaconApiGenesisProvider struct { - httpClient http.Client - url string + jsonRestHandler jsonRestHandler } func (c beaconApiValidatorClient) waitForChainStart(ctx context.Context) (*ethpb.ChainStartResponse, error) { @@ -65,29 +63,12 @@ func (c beaconApiValidatorClient) waitForChainStart(ctx context.Context) (*ethpb return chainStartResponse, nil } +// GetGenesis gets the genesis information from the beacon node via the /eth/v1/beacon/genesis endpoint func (c beaconApiGenesisProvider) GetGenesis() (*rpcmiddleware.GenesisResponse_GenesisJson, *apimiddleware.DefaultErrorJson, error) { - resp, err := c.httpClient.Get(c.url + "/eth/v1/beacon/genesis") - if err != nil { - return nil, nil, errors.Wrap(err, "failed to query REST API genesis endpoint") - } - defer func() { - if err = resp.Body.Close(); err != nil { - return - } - }() - - if resp.StatusCode != http.StatusOK { - errorJson := &apimiddleware.DefaultErrorJson{} - if err := json.NewDecoder(resp.Body).Decode(&errorJson); err != nil { - return nil, nil, errors.Wrap(err, "failed to decode response body genesis error json") - } - - return nil, errorJson, errors.Errorf("error %d: %s", errorJson.Code, errorJson.Message) - } - genesisJson := &rpcmiddleware.GenesisResponseJson{} - if err := json.NewDecoder(resp.Body).Decode(&genesisJson); err != nil { - return nil, nil, errors.Wrap(err, "failed to decode response body genesis json") + errorJson, err := c.jsonRestHandler.GetRestJsonResponse("/eth/v1/beacon/genesis", genesisJson) + if err != nil { + return nil, errorJson, errors.Wrap(err, "failed to get json response") } if genesisJson.Data == nil { diff --git a/validator/client/beacon-api/genesis_test.go b/validator/client/beacon-api/genesis_test.go index 510ce2ed0..10f52e47b 100644 --- a/validator/client/beacon-api/genesis_test.go +++ b/validator/client/beacon-api/genesis_test.go @@ -4,25 +4,40 @@ package beacon_api import ( - "net/http" - "net/http/httptest" "testing" - "time" + "github.com/golang/mock/gomock" + "github.com/pkg/errors" "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" "github.com/prysmaticlabs/prysm/v3/testing/assert" "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" ) func TestGetGenesis_ValidGenesis(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - genesisProvider := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.GenesisResponseJson{ + Data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "1234", + GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + }, + }, + ).Times(1) + + genesisProvider := &beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} resp, httpError, err := genesisProvider.GetGenesis() assert.NoError(t, err) assert.Equal(t, (*apimiddleware.DefaultErrorJson)(nil), httpError) @@ -32,71 +47,50 @@ func TestGetGenesis_ValidGenesis(t *testing.T) { } func TestGetGenesis_NilData(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(nil)) - defer server.Close() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - genesisProvider := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.GenesisResponseJson{Data: nil}, + ).Times(1) + + genesisProvider := &beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} _, httpError, err := genesisProvider.GetGenesis() assert.Equal(t, (*apimiddleware.DefaultErrorJson)(nil), httpError) assert.ErrorContains(t, "genesis data is nil", err) } -func TestGetGenesis_InvalidJsonGenesis(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte("foo")) - require.NoError(t, err) - })) - defer server.Close() +func TestGetGenesis_JsonResponseError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - genesisProvider := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} + expectedHttpErrorJson := &apimiddleware.DefaultErrorJson{ + Message: "http error message", + Code: 999, + } + + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + expectedHttpErrorJson, + errors.New("some specific json response error"), + ).Times(1) + + genesisProvider := &beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} _, httpError, err := genesisProvider.GetGenesis() - assert.Equal(t, (*apimiddleware.DefaultErrorJson)(nil), httpError) - assert.ErrorContains(t, "failed to decode response body genesis json", err) -} - -func TestGetGenesis_InvalidJsonError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(invalidJsonErrHandler)) - defer server.Close() - - genesisProvider := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} - _, httpError, err := genesisProvider.GetGenesis() - assert.Equal(t, (*apimiddleware.DefaultErrorJson)(nil), httpError) - assert.ErrorContains(t, "failed to decode response body genesis error json", err) -} - -func TestGetGenesis_404Error(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(notFoundErrHandler)) - defer server.Close() - - validatorClient := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} - _, httpError, err := validatorClient.GetGenesis() - require.NotNil(t, httpError) - assert.Equal(t, http.StatusNotFound, httpError.Code) - assert.Equal(t, "Not found", httpError.Message) - assert.ErrorContains(t, "error 404: Not found", err) -} - -func TestGetGenesis_500Error(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(internalServerErrHandler)) - defer server.Close() - - validatorClient := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: time.Second * 5}} - _, httpError, err := validatorClient.GetGenesis() - require.NotNil(t, httpError) - assert.Equal(t, http.StatusInternalServerError, httpError.Code) - assert.Equal(t, "Internal server error", httpError.Message) - assert.ErrorContains(t, "error 500: Internal server error", err) -} - -func TestGetGenesis_Timeout(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() - - genesisProvider := &beaconApiGenesisProvider{url: server.URL, httpClient: http.Client{Timeout: 1}} - _, httpError, err := genesisProvider.GetGenesis() - assert.Equal(t, (*apimiddleware.DefaultErrorJson)(nil), httpError) - assert.ErrorContains(t, "failed to query REST API genesis endpoint", err) + assert.ErrorContains(t, "failed to get json response", err) + assert.ErrorContains(t, "some specific json response error", err) + assert.DeepEqual(t, expectedHttpErrorJson, httpError) } diff --git a/validator/client/beacon-api/handlers_test.go b/validator/client/beacon-api/handlers_test.go deleted file mode 100644 index 1327e001d..000000000 --- a/validator/client/beacon-api/handlers_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build use_beacon_api -// +build use_beacon_api - -package beacon_api - -import ( - "encoding/json" - "net/http" - - "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" - rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" -) - -func internalServerErrHandler(w http.ResponseWriter, r *http.Request) { - internalErrorJson := &apimiddleware.DefaultErrorJson{ - Code: http.StatusInternalServerError, - Message: "Internal server error", - } - - marshalledError, err := json.Marshal(internalErrorJson) - if err != nil { - panic(err) - } - - w.WriteHeader(http.StatusInternalServerError) - _, err = w.Write(marshalledError) - if err != nil { - panic(err) - } -} - -func notFoundErrHandler(w http.ResponseWriter, r *http.Request) { - internalErrorJson := &apimiddleware.DefaultErrorJson{ - Code: http.StatusNotFound, - Message: "Not found", - } - - marshalledError, err := json.Marshal(internalErrorJson) - if err != nil { - panic(err) - } - - w.WriteHeader(http.StatusNotFound) - _, err = w.Write(marshalledError) - if err != nil { - panic(err) - } -} - -func invalidErr999Handler(w http.ResponseWriter, r *http.Request) { - internalErrorJson := &apimiddleware.DefaultErrorJson{ - Code: 999, - Message: "Invalid error", - } - - marshalledError, err := json.Marshal(internalErrorJson) - if err != nil { - panic(err) - } - - w.WriteHeader(999) - _, err = w.Write(marshalledError) - if err != nil { - panic(err) - } -} - -func invalidJsonErrHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, err := w.Write([]byte("foo")) - if err != nil { - panic(err) - } -} - -func invalidJsonResultHandler(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte("foo")) - if err != nil { - panic(err) - } -} - -func createGenesisHandler(data *rpcmiddleware.GenesisResponse_GenesisJson) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - genesisResponseJson := &rpcmiddleware.GenesisResponseJson{Data: data} - marshalledResponse, err := json.Marshal(genesisResponseJson) - if err != nil { - panic(err) - } - - _, err = w.Write(marshalledResponse) - if err != nil { - panic(err) - } - }) -} diff --git a/validator/client/beacon-api/index.go b/validator/client/beacon-api/index.go new file mode 100644 index 000000000..35427113a --- /dev/null +++ b/validator/client/beacon-api/index.go @@ -0,0 +1,35 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "strconv" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/pkg/errors" + types "github.com/prysmaticlabs/prysm/v3/consensus-types/primitives" + ethpb "github.com/prysmaticlabs/prysm/v3/proto/prysm/v1alpha1" +) + +func (c beaconApiValidatorClient) validatorIndex(in *ethpb.ValidatorIndexRequest) (*ethpb.ValidatorIndexResponse, error) { + stringPubKey := hexutil.Encode(in.PublicKey) + + stateValidator, err := c.getStateValidators([]string{stringPubKey}) + if err != nil { + return nil, errors.Wrap(err, "failed to get validator state") + } + + if len(stateValidator.Data) == 0 { + return nil, errors.Errorf("could not find validator index for public key `%s`", stringPubKey) + } + + stringValidatorIndex := stateValidator.Data[0].Index + + index, err := strconv.ParseUint(stringValidatorIndex, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "failed to parse validator index") + } + + return ðpb.ValidatorIndexResponse{Index: types.ValidatorIndex(index)}, nil +} diff --git a/validator/client/beacon-api/index_test.go b/validator/client/beacon-api/index_test.go new file mode 100644 index 000000000..094a87e1f --- /dev/null +++ b/validator/client/beacon-api/index_test.go @@ -0,0 +1,181 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "context" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/golang/mock/gomock" + "github.com/pkg/errors" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + types "github.com/prysmaticlabs/prysm/v3/consensus-types/primitives" + ethpb "github.com/prysmaticlabs/prysm/v3/proto/prysm/v1alpha1" + "github.com/prysmaticlabs/prysm/v3/testing/assert" + "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" +) + +const stringPubKey = "0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13" + +func getPubKeyAndURL(t *testing.T, stringPubkey string) ([]byte, string) { + baseUrl := "/eth/v1/beacon/states/head/validators" + url := fmt.Sprintf("%s?id=%s", baseUrl, stringPubKey) + + pubKey, err := hexutil.Decode(stringPubKey) + require.NoError(t, err) + + return pubKey, url +} + +func TestIndex_Nominal(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + pubKey, url := getPubKeyAndURL(t, stringPubKey) + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.StateValidatorsResponseJson{ + Data: []*rpcmiddleware.ValidatorContainerJson{ + { + Index: "55293", + Status: "active_ongoing", + Validator: &rpcmiddleware.ValidatorJson{ + PublicKey: stringPubKey, + }, + }, + }, + }, + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + + validatorIndex, err := validatorClient.ValidatorIndex( + context.Background(), + ðpb.ValidatorIndexRequest{ + PublicKey: pubKey, + }, + ) + + require.NoError(t, err) + assert.Equal(t, types.ValidatorIndex(55293), validatorIndex.Index) +} + +func TestIndex_UnexistingValidator(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + pubKey, url := getPubKeyAndURL(t, stringPubKey) + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.StateValidatorsResponseJson{ + Data: []*rpcmiddleware.ValidatorContainerJson{}, + }, + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + + _, err := validatorClient.ValidatorIndex( + context.Background(), + ðpb.ValidatorIndexRequest{ + PublicKey: pubKey, + }, + ) + + wanted := "could not find validator index for public key `0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13`" + assert.ErrorContains(t, wanted, err) +} + +func TestIndex_BadIndexError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + pubKey, url := getPubKeyAndURL(t, stringPubKey) + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.StateValidatorsResponseJson{ + Data: []*rpcmiddleware.ValidatorContainerJson{ + { + Index: "This is not an index", + Status: "active_ongoing", + Validator: &rpcmiddleware.ValidatorJson{ + PublicKey: stringPubKey, + }, + }, + }, + }, + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + + _, err := validatorClient.ValidatorIndex( + context.Background(), + ðpb.ValidatorIndexRequest{ + PublicKey: pubKey, + }, + ) + + assert.ErrorContains(t, "failed to parse validator index", err) +} + +func TestIndex_JsonResponseError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + pubKey, url := getPubKeyAndURL(t, stringPubKey) + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + errors.New("some specific json error"), + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + + _, err := validatorClient.ValidatorIndex( + context.Background(), + ðpb.ValidatorIndexRequest{ + PublicKey: pubKey, + }, + ) + + assert.ErrorContains(t, "failed to get validator state", err) +} diff --git a/validator/client/beacon-api/json_rest_handler.go b/validator/client/beacon-api/json_rest_handler.go new file mode 100644 index 000000000..e88e4c078 --- /dev/null +++ b/validator/client/beacon-api/json_rest_handler.go @@ -0,0 +1,55 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "encoding/json" + "net/http" + + "github.com/pkg/errors" + "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" +) + +type jsonRestHandler interface { + GetRestJsonResponse(query string, responseJson interface{}) (*apimiddleware.DefaultErrorJson, error) +} + +type beaconApiJsonRestHandler struct { + httpClient http.Client + host string +} + +// GetRestJsonResponse sends a GET requests to apiEndpoint and decodes the response body as a JSON object into responseJson. +// If an HTTP error is returned, the body is decoded as a DefaultErrorJson JSON object instead and returned as the first return value. +func (c beaconApiJsonRestHandler) GetRestJsonResponse(apiEndpoint string, responseJson interface{}) (*apimiddleware.DefaultErrorJson, error) { + if responseJson == nil { + return nil, errors.New("responseJson is nil") + } + + url := c.host + apiEndpoint + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, errors.Wrapf(err, "failed to query REST API %s", url) + } + defer func() { + if err := resp.Body.Close(); err != nil { + return + } + }() + + if resp.StatusCode != http.StatusOK { + errorJson := &apimiddleware.DefaultErrorJson{} + if err := json.NewDecoder(resp.Body).Decode(errorJson); err != nil { + return nil, errors.Wrapf(err, "failed to decode error json for %s", url) + } + + return errorJson, errors.Errorf("error %d: %s", errorJson.Code, errorJson.Message) + } + + if err := json.NewDecoder(resp.Body).Decode(responseJson); err != nil { + return nil, errors.Wrapf(err, "failed to decode response json for %s", url) + } + + return nil, nil +} diff --git a/validator/client/beacon-api/json_rest_handler_test.go b/validator/client/beacon-api/json_rest_handler_test.go new file mode 100644 index 000000000..187a1cf8c --- /dev/null +++ b/validator/client/beacon-api/json_rest_handler_test.go @@ -0,0 +1,192 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + "github.com/prysmaticlabs/prysm/v3/testing/assert" + "github.com/prysmaticlabs/prysm/v3/testing/require" +) + +func TestGetRestJsonResponse_Valid(t *testing.T) { + const endpoint = "/example/rest/api/endpoint" + + genesisJson := &rpcmiddleware.GenesisResponseJson{ + Data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "123", + GenesisValidatorsRoot: "0x456", + GenesisForkVersion: "0x789", + }, + } + + mux := http.NewServeMux() + mux.HandleFunc(endpoint, func(w http.ResponseWriter, r *http.Request) { + // Make sure the url parameters match + assert.Equal(t, "abc", r.URL.Query().Get("arg1")) + assert.Equal(t, "def", r.URL.Query().Get("arg2")) + + marshalledJson, err := json.Marshal(genesisJson) + require.NoError(t, err) + + _, err = w.Write(marshalledJson) + require.NoError(t, err) + }) + server := httptest.NewServer(mux) + defer server.Close() + + jsonRestHandler := beaconApiJsonRestHandler{ + httpClient: http.Client{Timeout: time.Second * 5}, + host: server.URL, + } + + responseJson := &rpcmiddleware.GenesisResponseJson{} + _, err := jsonRestHandler.GetRestJsonResponse(endpoint+"?arg1=abc&arg2=def", responseJson) + assert.NoError(t, err) + assert.DeepEqual(t, genesisJson, responseJson) +} + +func TestGetRestJsonResponse_Error(t *testing.T) { + const endpoint = "/example/rest/api/endpoint" + + testCases := []struct { + name string + funcHandler func(w http.ResponseWriter, r *http.Request) + expectedErrorJson *apimiddleware.DefaultErrorJson + expectedErrorMessage string + timeout time.Duration + responseJson interface{} + }{ + { + name: "nil response json", + funcHandler: invalidJsonResponseHandler, + expectedErrorMessage: "responseJson is nil", + timeout: time.Second * 5, + responseJson: nil, + }, + { + name: "400 error", + funcHandler: httpErrorJsonHandler(http.StatusBadRequest, "Bad request"), + expectedErrorMessage: "error 400: Bad request", + expectedErrorJson: &apimiddleware.DefaultErrorJson{ + Code: http.StatusBadRequest, + Message: "Bad request", + }, + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "404 error", + funcHandler: httpErrorJsonHandler(http.StatusNotFound, "Not found"), + expectedErrorMessage: "error 404: Not found", + expectedErrorJson: &apimiddleware.DefaultErrorJson{ + Code: http.StatusNotFound, + Message: "Not found", + }, + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "500 error", + funcHandler: httpErrorJsonHandler(http.StatusInternalServerError, "Internal server error"), + expectedErrorMessage: "error 500: Internal server error", + expectedErrorJson: &apimiddleware.DefaultErrorJson{ + Code: http.StatusInternalServerError, + Message: "Internal server error", + }, + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "999 error", + funcHandler: httpErrorJsonHandler(999, "Invalid error"), + expectedErrorMessage: "error 999: Invalid error", + expectedErrorJson: &apimiddleware.DefaultErrorJson{ + Code: 999, + Message: "Invalid error", + }, + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "bad error json formatting", + funcHandler: invalidJsonErrHandler, + expectedErrorMessage: "failed to decode error json", + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "bad response json formatting", + funcHandler: invalidJsonResponseHandler, + expectedErrorMessage: "failed to decode response json", + timeout: time.Second * 5, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + { + name: "timeout", + funcHandler: httpErrorJsonHandler(http.StatusNotFound, "Not found"), + expectedErrorMessage: "failed to query REST API", + timeout: 1, + responseJson: &rpcmiddleware.GenesisResponseJson{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc(endpoint, testCase.funcHandler) + server := httptest.NewServer(mux) + defer server.Close() + + jsonRestHandler := beaconApiJsonRestHandler{ + httpClient: http.Client{Timeout: testCase.timeout}, + host: server.URL, + } + errorJson, err := jsonRestHandler.GetRestJsonResponse(endpoint, testCase.responseJson) + assert.ErrorContains(t, testCase.expectedErrorMessage, err) + assert.DeepEqual(t, testCase.expectedErrorJson, errorJson) + }) + } +} + +func httpErrorJsonHandler(statusCode int, errorMessage string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + errorJson := &apimiddleware.DefaultErrorJson{ + Code: statusCode, + Message: errorMessage, + } + + marshalledError, err := json.Marshal(errorJson) + if err != nil { + panic(err) + } + + w.WriteHeader(statusCode) + _, err = w.Write(marshalledError) + if err != nil { + panic(err) + } + } +} + +func invalidJsonErrHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte("foo")) + if err != nil { + panic(err) + } +} + +func invalidJsonResponseHandler(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("foo")) + if err != nil { + panic(err) + } +} diff --git a/validator/client/beacon-api/mock/BUILD.bazel b/validator/client/beacon-api/mock/BUILD.bazel index c7e191497..6a3db0d56 100644 --- a/validator/client/beacon-api/mock/BUILD.bazel +++ b/validator/client/beacon-api/mock/BUILD.bazel @@ -1,11 +1,13 @@ load("@prysm//tools/go:def.bzl", "go_library") -# gazelle:build_tags use_beacon_api go_library( name = "go_default_library", - srcs = ["genesis_mock.go"], + srcs = [ + "genesis_mock.go", + "json_rest_handler_mock.go", + ], importpath = "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock", - visibility = ["//validator:__subpackages__"], + visibility = ["//visibility:public"], deps = [ "//api/gateway/apimiddleware:go_default_library", "//beacon-chain/rpc/apimiddleware:go_default_library", diff --git a/validator/client/beacon-api/mock/json_rest_handler_mock.go b/validator/client/beacon-api/mock/json_rest_handler_mock.go new file mode 100644 index 000000000..1352145bc --- /dev/null +++ b/validator/client/beacon-api/mock/json_rest_handler_mock.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: validator/client/beacon-api/json_rest_handler.go + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + apimiddleware "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" +) + +// MockjsonRestHandler is a mock of jsonRestHandler interface. +type MockjsonRestHandler struct { + ctrl *gomock.Controller + recorder *MockjsonRestHandlerMockRecorder +} + +// MockjsonRestHandlerMockRecorder is the mock recorder for MockjsonRestHandler. +type MockjsonRestHandlerMockRecorder struct { + mock *MockjsonRestHandler +} + +// NewMockjsonRestHandler creates a new mock instance. +func NewMockjsonRestHandler(ctrl *gomock.Controller) *MockjsonRestHandler { + mock := &MockjsonRestHandler{ctrl: ctrl} + mock.recorder = &MockjsonRestHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockjsonRestHandler) EXPECT() *MockjsonRestHandlerMockRecorder { + return m.recorder +} + +// GetRestJsonResponse mocks base method. +func (m *MockjsonRestHandler) GetRestJsonResponse(query string, responseJson interface{}) (*apimiddleware.DefaultErrorJson, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRestJsonResponse", query, responseJson) + ret0, _ := ret[0].(*apimiddleware.DefaultErrorJson) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRestJsonResponse indicates an expected call of GetRestJsonResponse. +func (mr *MockjsonRestHandlerMockRecorder) GetRestJsonResponse(query, responseJson interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRestJsonResponse", reflect.TypeOf((*MockjsonRestHandler)(nil).GetRestJsonResponse), query, responseJson) +} diff --git a/validator/client/beacon-api/state_validators.go b/validator/client/beacon-api/state_validators.go new file mode 100644 index 000000000..4e948bfc3 --- /dev/null +++ b/validator/client/beacon-api/state_validators.go @@ -0,0 +1,37 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + neturl "net/url" + + "github.com/pkg/errors" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" +) + +func (c *beaconApiValidatorClient) getStateValidators(stringPubkeys []string) (*rpcmiddleware.StateValidatorsResponseJson, error) { + params := neturl.Values{} + + for _, stringPubkey := range stringPubkeys { + params.Add("id", stringPubkey) + } + + url := buildURL( + "/eth/v1/beacon/states/head/validators", + params, + ) + + stateValidatorsJson := &rpcmiddleware.StateValidatorsResponseJson{} + + _, err := c.jsonRestHandler.GetRestJsonResponse(url, stateValidatorsJson) + if err != nil { + return nil, errors.Wrap(err, "failed to get json response") + } + + if stateValidatorsJson.Data == nil { + return nil, errors.New("stateValidatorsJson.Data is nil") + } + + return stateValidatorsJson, nil +} diff --git a/validator/client/beacon-api/state_validators_test.go b/validator/client/beacon-api/state_validators_test.go new file mode 100644 index 000000000..04a0e4bd9 --- /dev/null +++ b/validator/client/beacon-api/state_validators_test.go @@ -0,0 +1,133 @@ +//go:build use_beacon_api +// +build use_beacon_api + +package beacon_api + +import ( + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/pkg/errors" + rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" + "github.com/prysmaticlabs/prysm/v3/testing/assert" + "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" +) + +func TestGetStateValidators_Nominal(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + url := strings.Join([]string{ + "/eth/v1/beacon/states/head/validators?", + "id=0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13&", // active_ongoing + "id=0x80000e851c0f53c3246ff726d7ff7766661ca5e12a07c45c114d208d54f0f8233d4380b2e9aff759d69795d1df905526&", // active_exiting + "id=0x424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242&", // does not exist + "id=0x800015473bdc3a7f45ef8eb8abc598bc20021e55ad6e6ad1d745aaef9730dd2c28ec08bf42df18451de94dd4a6d24ec5", // exited_slashed + }, "") + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + wanted := []*rpcmiddleware.ValidatorContainerJson{ + { + Index: "55293", + Status: "active_ongoing", + Validator: &rpcmiddleware.ValidatorJson{ + PublicKey: "0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13", + }, + }, + { + Index: "55294", + Status: "active_exiting", + Validator: &rpcmiddleware.ValidatorJson{ + PublicKey: "0x80000e851c0f53c3246ff726d7ff7766661ca5e12a07c45c114d208d54f0f8233d4380b2e9aff759d69795d1df905526", + }, + }, + { + Index: "55295", + Status: "exited_slashed", + Validator: &rpcmiddleware.ValidatorJson{ + PublicKey: "0x800015473bdc3a7f45ef8eb8abc598bc20021e55ad6e6ad1d745aaef9730dd2c28ec08bf42df18451de94dd4a6d24ec5", + }, + }, + } + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.StateValidatorsResponseJson{ + Data: wanted, + }, + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + actual, err := validatorClient.getStateValidators([]string{ + "0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13", // active_ongoing + "0x80000e851c0f53c3246ff726d7ff7766661ca5e12a07c45c114d208d54f0f8233d4380b2e9aff759d69795d1df905526", // active_exiting + "0x424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242", // does not exist + "0x800015473bdc3a7f45ef8eb8abc598bc20021e55ad6e6ad1d745aaef9730dd2c28ec08bf42df18451de94dd4a6d24ec5", // exited_slashed + }) + require.NoError(t, err) + assert.DeepEqual(t, wanted, actual.Data) +} + +func TestGetStateValidators_GetRestJsonResponseOnError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + url := "/eth/v1/beacon/states/head/validators?id=0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13" + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + errors.New("an error"), + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + _, err := validatorClient.getStateValidators([]string{ + "0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13", // active_ongoing + }) + assert.ErrorContains(t, "an error", err) + assert.ErrorContains(t, "failed to get json response", err) +} + +func TestGetStateValidators_DataIsNil(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + url := "/eth/v1/beacon/states/head/validators?id=0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13" + + stateValidatorsResponseJson := rpcmiddleware.StateValidatorsResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + jsonRestHandler.EXPECT().GetRestJsonResponse( + url, + &stateValidatorsResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.StateValidatorsResponseJson{ + Data: nil, + }, + ).Times(1) + + validatorClient := beaconApiValidatorClient{jsonRestHandler: jsonRestHandler} + _, err := validatorClient.getStateValidators([]string{ + "0x8000091c2ae64ee414a54c1cc1fc67dec663408bc636cb86756e0200e41a75c8f86603f104f02c856983d2783116be13", // active_ongoing + }) + assert.ErrorContains(t, "stateValidatorsJson.Data is nil", err) +} diff --git a/validator/client/beacon-api/wait_for_chain_start_test.go b/validator/client/beacon-api/wait_for_chain_start_test.go index a2b68f29a..f29dc4ffb 100644 --- a/validator/client/beacon-api/wait_for_chain_start_test.go +++ b/validator/client/beacon-api/wait_for_chain_start_test.go @@ -5,27 +5,44 @@ package beacon_api import ( "context" + "errors" "net/http" - "net/http/httptest" "testing" - "time" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/golang/mock/gomock" + "github.com/prysmaticlabs/prysm/v3/api/gateway/apimiddleware" rpcmiddleware "github.com/prysmaticlabs/prysm/v3/beacon-chain/rpc/apimiddleware" "github.com/prysmaticlabs/prysm/v3/testing/assert" "github.com/prysmaticlabs/prysm/v3/testing/require" + "github.com/prysmaticlabs/prysm/v3/validator/client/beacon-api/mock" "google.golang.org/protobuf/types/known/emptypb" ) func TestWaitForChainStart_ValidGenesis(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.GenesisResponseJson{ + Data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "1234", + GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + }, + }, + ).Times(1) + genesisProvider := beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} + validatorClient := beaconApiValidatorClient{genesisProvider: genesisProvider} resp, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) assert.NoError(t, err) @@ -38,132 +55,131 @@ func TestWaitForChainStart_ValidGenesis(t *testing.T) { assert.DeepEqual(t, expectedRoot, resp.GenesisValidatorsRoot) } -func TestWaitForChainStart_NilData(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(nil)) - defer server.Close() +func TestWaitForChainStart_BadGenesis(t *testing.T) { + testCases := []struct { + name string + data *rpcmiddleware.GenesisResponse_GenesisJson + errorMessage string + }{ + { + name: "nil data", + data: nil, + errorMessage: "failed to get genesis data", + }, + { + name: "invalid time", + data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "foo", + GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + }, + errorMessage: "failed to parse genesis time: foo", + }, + { + name: "invalid root", + data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "1234", + GenesisValidatorsRoot: "0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + }, + errorMessage: "invalid genesis validators root: ", + }, + } - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.GenesisResponseJson{ + Data: testCase.data, + }, + ).Times(1) + + genesisProvider := beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} + validatorClient := beaconApiValidatorClient{genesisProvider: genesisProvider} + _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) + assert.ErrorContains(t, testCase.errorMessage, err) + }) + } +} + +func TestWaitForChainStart_JsonResponseError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + errors.New("some specific json error"), + ).Times(1) + + genesisProvider := beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} + validatorClient := beaconApiValidatorClient{genesisProvider: genesisProvider} _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) assert.ErrorContains(t, "failed to get genesis data", err) + assert.ErrorContains(t, "some specific json error", err) } -func TestWaitForChainStart_InvalidTime(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "foo", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() +// For WaitForChainStart, error 404 just means that we keep retrying until the information becomes available +func TestWaitForChainStart_JsonResponseError404(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "failed to parse genesis time", err) -} - -func TestWaitForChainStart_EmptyTime(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "failed to parse genesis time", err) -} - -func TestWaitForChainStart_InvalidRoot(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", - })) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "invalid genesis validators root", err) -} - -func TestWaitForChainStart_EmptyRoot(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "", - })) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "invalid genesis validators root", err) -} - -func TestWaitForChainStart_InvalidJsonGenesis(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte("foo")) - require.NoError(t, err) - })) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "failed to get genesis data", err) -} - -func TestWaitForChainStart_InternalServerError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(internalServerErrHandler)) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "500: Internal server error", err) -} - -func TestWaitForChainStart_NotFoundErrorContextCancelled(t *testing.T) { - // WaitForChainStart blocks until the error is not 404, but it needs to listen to context cancellations - server := httptest.NewServer(http.HandlerFunc(notFoundErrHandler)) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - - // Create a context that can be canceled - ctx, cancel := context.WithCancel(context.Background()) - - // Cancel the context after 1 second - go func(ctx context.Context) { - time.Sleep(time.Second) - cancel() - }(ctx) - - _, err := validatorClient.WaitForChainStart(ctx, &emptypb.Empty{}) - assert.ErrorContains(t, "context canceled", err) -} - -// This test makes sure that we handle even errors not specified in the Beacon API spec -func TestWaitForChainStart_UnknownError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(invalidErr999Handler)) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "999: Invalid error", err) -} - -// Make sure that we fail gracefully if the error json is not valid json -func TestWaitForChainStart_InvalidJsonError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(invalidJsonErrHandler)) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, time.Second*5) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "failed to get genesis data", err) -} - -func TestWaitForChainStart_Timeout(t *testing.T) { - server := httptest.NewServer(createGenesisHandler(&rpcmiddleware.GenesisResponse_GenesisJson{ - GenesisTime: "1234", - GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - })) - defer server.Close() - - validatorClient := NewBeaconApiValidatorClient(server.URL, 1) - _, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) - assert.ErrorContains(t, "failed to get genesis data", err) + genesisResponseJson := rpcmiddleware.GenesisResponseJson{} + jsonRestHandler := mock.NewMockjsonRestHandler(ctrl) + + // First, mock a request that receives a 404 error (which means that the genesis data is not available yet) + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + &apimiddleware.DefaultErrorJson{ + Code: http.StatusNotFound, + Message: "404 error", + }, + errors.New("404 error"), + ).Times(1) + + // After receiving a 404 error, mock a request that actually has genesis data available + jsonRestHandler.EXPECT().GetRestJsonResponse( + "/eth/v1/beacon/genesis", + &genesisResponseJson, + ).Return( + nil, + nil, + ).SetArg( + 1, + rpcmiddleware.GenesisResponseJson{ + Data: &rpcmiddleware.GenesisResponse_GenesisJson{ + GenesisTime: "1234", + GenesisValidatorsRoot: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", + }, + }, + ).Times(1) + + genesisProvider := beaconApiGenesisProvider{jsonRestHandler: jsonRestHandler} + validatorClient := beaconApiValidatorClient{genesisProvider: genesisProvider} + resp, err := validatorClient.WaitForChainStart(context.Background(), &emptypb.Empty{}) + assert.NoError(t, err) + + require.NotNil(t, resp) + assert.Equal(t, true, resp.Started) + assert.Equal(t, uint64(1234), resp.GenesisTime) + + expectedRoot, err := hexutil.Decode("0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2") + require.NoError(t, err) + assert.DeepEqual(t, expectedRoot, resp.GenesisValidatorsRoot) }