Keymanager API: Add validator voluntary exit endpoint (#12299)

* Initial setup

* Fix + Cleanup

* Add query

* Fix

* Add epoch

* James' review part 1

* James' review part 2

* James' review part 3

* Radek' review

* Gazelle

* Fix cycle

* Start unit test

* fixing part of the test

* Mostly fix test

* Fix tests

* Cleanup

* Handle error

* Remove times

* Fix all tests

* Fix accidental deletion

* Unmarshal epoch

* Add custom_type

* Small fix

* Fix epoch

* Lint fix

* Add test + fix empty query panic

* Add comment

* Fix regex

* Add correct error message

* Change current epoch to use slot

* Return error if incorrect epoch passed

* Remove redundant type conversion

* Fix tests

* gaz

* Remove nodeClient + pass slot

* Remove slot from parameters

* Fix tests

* Fix test attempt 2

* Fix test attempt 2

* Remove nodeClient from ProposeExit

* Fix

* Fix tests

---------

Co-authored-by: james-prysm <james@prysmaticlabs.com>
Co-authored-by: james-prysm <90280386+james-prysm@users.noreply.github.com>
This commit is contained in:
Sammy Rosso
2023-06-21 21:06:16 +02:00
committed by GitHub
parent c018981951
commit 20f4d21b83
17 changed files with 1185 additions and 433 deletions

View File

@@ -134,7 +134,6 @@ func TestExitAccountsCli_OK_AllPublicKeys(t *testing.T) {
mockNodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Times(2).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
mockValidatorClient.EXPECT().

File diff suppressed because it is too large Load Diff

View File

@@ -597,6 +597,76 @@ func local_request_KeyManagement_DeleteGasLimit_0(ctx context.Context, marshaler
}
func request_KeyManagement_SetVoluntaryExit_0(ctx context.Context, marshaler runtime.Marshaler, client KeyManagementClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq SetVoluntaryExitRequest
var metadata runtime.ServerMetadata
newReader, berr := utilities.IOReaderFactory(req.Body)
if berr != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr)
}
if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
var (
val string
ok bool
err error
_ = err
)
val, ok = pathParams["pubkey"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "pubkey")
}
pubkey, err := runtime.Bytes(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "pubkey", err)
}
protoReq.Pubkey = (pubkey)
msg, err := client.SetVoluntaryExit(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_KeyManagement_SetVoluntaryExit_0(ctx context.Context, marshaler runtime.Marshaler, server KeyManagementServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq SetVoluntaryExitRequest
var metadata runtime.ServerMetadata
newReader, berr := utilities.IOReaderFactory(req.Body)
if berr != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr)
}
if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
var (
val string
ok bool
err error
_ = err
)
val, ok = pathParams["pubkey"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "pubkey")
}
pubkey, err := runtime.Bytes(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "pubkey", err)
}
protoReq.Pubkey = (pubkey)
msg, err := server.SetVoluntaryExit(ctx, &protoReq)
return msg, metadata, err
}
// RegisterKeyManagementHandlerServer registers the http handlers for service KeyManagement to "mux".
// UnaryRPC :call KeyManagementServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@@ -879,6 +949,29 @@ func RegisterKeyManagementHandlerServer(ctx context.Context, mux *runtime.ServeM
})
mux.Handle("POST", pattern_KeyManagement_SetVoluntaryExit_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
rctx, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/ethereum.eth.service.KeyManagement/SetVoluntaryExit")
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_KeyManagement_SetVoluntaryExit_0(rctx, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
ctx = runtime.NewServerMetadataContext(ctx, md)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
forward_KeyManagement_SetVoluntaryExit_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@@ -1160,6 +1253,26 @@ func RegisterKeyManagementHandlerClient(ctx context.Context, mux *runtime.ServeM
})
mux.Handle("POST", pattern_KeyManagement_SetVoluntaryExit_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
rctx, err := runtime.AnnotateContext(ctx, mux, req, "/ethereum.eth.service.KeyManagement/SetVoluntaryExit")
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_KeyManagement_SetVoluntaryExit_0(rctx, inboundMarshaler, client, req, pathParams)
ctx = runtime.NewServerMetadataContext(ctx, md)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
forward_KeyManagement_SetVoluntaryExit_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@@ -1187,6 +1300,8 @@ var (
pattern_KeyManagement_SetGasLimit_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"internal", "eth", "v1", "validator", "pubkey", "gas_limit"}, ""))
pattern_KeyManagement_DeleteGasLimit_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"internal", "eth", "v1", "validator", "pubkey", "gas_limit"}, ""))
pattern_KeyManagement_SetVoluntaryExit_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"internal", "eth", "v1", "validator", "pubkey", "voluntary_exit"}, ""))
)
var (
@@ -1213,4 +1328,6 @@ var (
forward_KeyManagement_SetGasLimit_0 = runtime.ForwardResponseMessage
forward_KeyManagement_DeleteGasLimit_0 = runtime.ForwardResponseMessage
forward_KeyManagement_SetVoluntaryExit_0 = runtime.ForwardResponseMessage
)

View File

@@ -18,6 +18,7 @@ package ethereum.eth.service;
import "google/api/annotations.proto";
import "google/protobuf/descriptor.proto";
import "google/protobuf/empty.proto";
import "proto/eth/ext/options.proto";
option csharp_namespace = "Ethereum.Eth.Service";
option go_package = "github.com/prysmaticlabs/prysm/v4/proto/eth/service";
@@ -217,6 +218,24 @@ service KeyManagement {
};
}
// SetVoluntaryExit creates a signed voluntary exit message and returns a VoluntaryExit object.
//
// Spec page: https://ethereum.github.io/keymanager-APIs/?urls.primaryName=dev#/Voluntary%20Exit/signVoluntaryExit
//
// HTTP response status codes:
// - 200: Successfully created and signed voluntary exit
// - 400: Bad request, malformed request
// - 401: Unauthorized, no token is found.
// - 403: Forbidden, a token is found but is invalid
// - 404: Path not found
// - 500: Validator internal error
rpc SetVoluntaryExit(SetVoluntaryExitRequest) returns (SetVoluntaryExitResponse) {
option (google.api.http) = {
post: "/internal/eth/v1/validator/{pubkey}/voluntary_exit",
body: "*"
};
}
}
message ListKeystoresResponse {
@@ -352,3 +371,20 @@ message SetGasLimitRequest {
message DeleteGasLimitRequest {
bytes pubkey = 1;
}
message SetVoluntaryExitRequest {
bytes pubkey = 1 [(ethereum.eth.ext.ssz_size) = "48"];
uint64 epoch = 2 [(ethereum.eth.ext.cast_type) = "github.com/prysmaticlabs/prysm/v4/consensus-types/primitives.Epoch"];
}
message SetVoluntaryExitResponse {
message SignedVoluntaryExit {
message VoluntaryExit {
uint64 epoch = 1 [(ethereum.eth.ext.cast_type) = "github.com/prysmaticlabs/prysm/v4/consensus-types/primitives.Epoch"];
uint64 validator_index = 2 [(ethereum.eth.ext.cast_type) = "github.com/prysmaticlabs/prysm/v4/consensus-types/primitives.ValidatorIndex"];
}
VoluntaryExit message = 1;
bytes signature = 2;
}
SignedVoluntaryExit data = 1;
}

View File

@@ -84,11 +84,19 @@ func PerformVoluntaryExit(
ctx context.Context, cfg PerformExitCfg,
) (rawExitedKeys [][]byte, formattedExitedKeys []string, err error) {
var rawNotExitedKeys [][]byte
genesisResponse, err := cfg.NodeClient.GetGenesis(ctx, &emptypb.Empty{})
if err != nil {
log.WithError(err).Errorf("voluntary exit failed: %v", err)
}
for i, key := range cfg.RawPubKeys {
// When output directory is present, only create the signed exit, but do not propose it.
// Otherwise, propose the exit immediately.
epoch, err := client.CurrentEpoch(genesisResponse.GenesisTime)
if err != nil {
log.WithError(err).Errorf("voluntary exit failed: %v", err)
}
if len(cfg.OutputDirectory) > 0 {
sve, err := client.CreateSignedVoluntaryExit(ctx, cfg.ValidatorClient, cfg.NodeClient, cfg.Keymanager.Sign, key)
sve, err := client.CreateSignedVoluntaryExit(ctx, cfg.ValidatorClient, cfg.Keymanager.Sign, key, epoch)
if err != nil {
rawNotExitedKeys = append(rawNotExitedKeys, key)
msg := err.Error()
@@ -101,7 +109,7 @@ func PerformVoluntaryExit(
} else if err := writeSignedVoluntaryExitJSON(ctx, sve, cfg.OutputDirectory); err != nil {
log.WithError(err).Error("failed to write voluntary exit")
}
} else if err := client.ProposeExit(ctx, cfg.ValidatorClient, cfg.NodeClient, cfg.Keymanager.Sign, key); err != nil {
} else if err := client.ProposeExit(ctx, cfg.ValidatorClient, cfg.Keymanager.Sign, key, epoch); err != nil {
rawNotExitedKeys = append(rawNotExitedKeys, key)
msg := err.Error()

View File

@@ -80,6 +80,7 @@ go_library(
"@com_github_prysmaticlabs_go_bitfield//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@io_bazel_rules_go//proto/wkt:empty_go_proto",
"@io_bazel_rules_go//proto/wkt:timestamp_go_proto",
"@io_opencensus_go//plugin/ocgrpc:go_default_library",
"@io_opencensus_go//trace:go_default_library",
"@org_golang_google_grpc//:go_default_library",
@@ -173,6 +174,5 @@ go_test(
"@org_golang_google_grpc//metadata:go_default_library",
"@org_golang_google_grpc//status:go_default_library",
"@org_golang_google_protobuf//types/known/emptypb:go_default_library",
"@org_golang_google_protobuf//types/known/timestamppb:go_default_library",
],
)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/golang/protobuf/ptypes/timestamp"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v4/async"
"github.com/prysmaticlabs/prysm/v4/beacon-chain/core/signing"
@@ -25,7 +26,6 @@ import (
"github.com/prysmaticlabs/prysm/v4/validator/client/iface"
"github.com/sirupsen/logrus"
"go.opencensus.io/trace"
"google.golang.org/protobuf/types/known/emptypb"
)
const domainDataErr = "could not get domain data"
@@ -198,14 +198,14 @@ func (v *validator) ProposeBlock(ctx context.Context, slot primitives.Slot, pubK
func ProposeExit(
ctx context.Context,
validatorClient iface.ValidatorClient,
nodeClient iface.NodeClient,
signer iface.SigningFunc,
pubKey []byte,
epoch primitives.Epoch,
) error {
ctx, span := trace.StartSpan(ctx, "validator.ProposeExit")
defer span.End()
signedExit, err := CreateSignedVoluntaryExit(ctx, validatorClient, nodeClient, signer, pubKey)
signedExit, err := CreateSignedVoluntaryExit(ctx, validatorClient, signer, pubKey, epoch)
if err != nil {
return errors.Wrap(err, "failed to create signed voluntary exit")
}
@@ -217,16 +217,22 @@ func ProposeExit(
span.AddAttributes(
trace.StringAttribute("exitRoot", fmt.Sprintf("%#x", exitResp.ExitRoot)),
)
return nil
}
func CurrentEpoch(genesisTime *timestamp.Timestamp) (primitives.Epoch, error) {
totalSecondsPassed := prysmTime.Now().Unix() - genesisTime.Seconds
currentSlot := primitives.Slot((uint64(totalSecondsPassed)) / params.BeaconConfig().SecondsPerSlot)
currentEpoch := slots.ToEpoch(currentSlot)
return currentEpoch, nil
}
func CreateSignedVoluntaryExit(
ctx context.Context,
validatorClient iface.ValidatorClient,
nodeClient iface.NodeClient,
signer iface.SigningFunc,
pubKey []byte,
epoch primitives.Epoch,
) (*ethpb.SignedVoluntaryExit, error) {
ctx, span := trace.StartSpan(ctx, "validator.CreateSignedVoluntaryExit")
defer span.End()
@@ -235,15 +241,12 @@ func CreateSignedVoluntaryExit(
if err != nil {
return nil, errors.Wrap(err, "gRPC call to get validator index failed")
}
genesisResponse, err := nodeClient.GetGenesis(ctx, &emptypb.Empty{})
exit := &ethpb.VoluntaryExit{Epoch: epoch, ValidatorIndex: indexResponse.Index}
slot, err := slots.EpochStart(epoch)
if err != nil {
return nil, errors.Wrap(err, "gRPC call to get genesis time failed")
return nil, errors.Wrap(err, "failed to retrieve slot")
}
totalSecondsPassed := prysmTime.Now().Unix() - genesisResponse.GenesisTime.Seconds
currentEpoch := primitives.Epoch(uint64(totalSecondsPassed) / uint64(params.BeaconConfig().SlotsPerEpoch.Mul(params.BeaconConfig().SecondsPerSlot)))
currentSlot := slots.CurrentSlot(uint64(genesisResponse.GenesisTime.AsTime().Unix()))
exit := &ethpb.VoluntaryExit{Epoch: currentEpoch, ValidatorIndex: indexResponse.Index}
sig, err := signVoluntaryExit(ctx, validatorClient, signer, pubKey, exit, currentSlot)
sig, err := signVoluntaryExit(ctx, validatorClient, signer, pubKey, exit, slot)
if err != nil {
return nil, errors.Wrap(err, "failed to sign voluntary exit")
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/prysmaticlabs/prysm/v4/beacon-chain/core/signing"
@@ -28,7 +27,6 @@ import (
testing2 "github.com/prysmaticlabs/prysm/v4/validator/db/testing"
"github.com/prysmaticlabs/prysm/v4/validator/graffiti"
logTest "github.com/sirupsen/logrus/hooks/test"
"google.golang.org/protobuf/types/known/timestamppb"
)
type mocks struct {
@@ -648,39 +646,15 @@ func TestProposeExit_ValidatorIndexFailed(t *testing.T) {
err := ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
params.BeaconConfig().GenesisEpoch,
)
assert.NotNil(t, err)
assert.ErrorContains(t, "uh oh", err)
assert.ErrorContains(t, "gRPC call to get validator index failed", err)
}
func TestProposeExit_GetGenesisFailed(t *testing.T) {
_, m, validatorKey, finish := setup(t)
defer finish()
m.validatorClient.EXPECT().
ValidatorIndex(gomock.Any(), gomock.Any()).
Return(nil, nil)
m.nodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(nil, errors.New("uh oh"))
err := ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
)
assert.NotNil(t, err)
assert.ErrorContains(t, "uh oh", err)
assert.ErrorContains(t, "gRPC call to get genesis time failed", err)
}
func TestProposeExit_DomainDataFailed(t *testing.T) {
_, m, validatorKey, finish := setup(t)
defer finish()
@@ -689,15 +663,6 @@ func TestProposeExit_DomainDataFailed(t *testing.T) {
ValidatorIndex(gomock.Any(), gomock.Any()).
Return(&ethpb.ValidatorIndexResponse{Index: 1}, nil)
// Any time in the past will suffice
genesisTime := &timestamppb.Timestamp{
Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
}
m.nodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
m.validatorClient.EXPECT().
DomainData(gomock.Any(), gomock.Any()).
Return(nil, errors.New("uh oh"))
@@ -705,9 +670,9 @@ func TestProposeExit_DomainDataFailed(t *testing.T) {
err := ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
params.BeaconConfig().GenesisEpoch,
)
assert.NotNil(t, err)
assert.ErrorContains(t, domainDataErr, err)
@@ -723,15 +688,6 @@ func TestProposeExit_DomainDataIsNil(t *testing.T) {
ValidatorIndex(gomock.Any(), gomock.Any()).
Return(&ethpb.ValidatorIndexResponse{Index: 1}, nil)
// Any time in the past will suffice
genesisTime := &timestamppb.Timestamp{
Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
}
m.nodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
m.validatorClient.EXPECT().
DomainData(gomock.Any(), gomock.Any()).
Return(nil, nil)
@@ -739,9 +695,9 @@ func TestProposeExit_DomainDataIsNil(t *testing.T) {
err := ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
params.BeaconConfig().GenesisEpoch,
)
assert.NotNil(t, err)
assert.ErrorContains(t, domainDataErr, err)
@@ -756,15 +712,6 @@ func TestProposeBlock_ProposeExitFailed(t *testing.T) {
ValidatorIndex(gomock.Any(), gomock.Any()).
Return(&ethpb.ValidatorIndexResponse{Index: 1}, nil)
// Any time in the past will suffice
genesisTime := &timestamppb.Timestamp{
Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
}
m.nodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
m.validatorClient.EXPECT().
DomainData(gomock.Any(), gomock.Any()).
Return(&ethpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil)
@@ -776,9 +723,9 @@ func TestProposeBlock_ProposeExitFailed(t *testing.T) {
err := ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
params.BeaconConfig().GenesisEpoch,
)
assert.NotNil(t, err)
assert.ErrorContains(t, "uh oh", err)
@@ -793,15 +740,6 @@ func TestProposeExit_BroadcastsBlock(t *testing.T) {
ValidatorIndex(gomock.Any(), gomock.Any()).
Return(&ethpb.ValidatorIndexResponse{Index: 1}, nil)
// Any time in the past will suffice
genesisTime := &timestamppb.Timestamp{
Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
}
m.nodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
m.validatorClient.EXPECT().
DomainData(gomock.Any(), gomock.Any()).
Return(&ethpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil)
@@ -813,9 +751,9 @@ func TestProposeExit_BroadcastsBlock(t *testing.T) {
assert.NoError(t, ProposeExit(
context.Background(),
m.validatorClient,
m.nodeClient,
m.signfunc,
validatorKey.PublicKey().Marshal(),
params.BeaconConfig().GenesisEpoch,
))
}

View File

@@ -102,6 +102,7 @@ go_test(
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",
"//config/validator/service:go_default_library",
"//consensus-types/primitives:go_default_library",
"//consensus-types/validator:go_default_library",
"//crypto/bls:go_default_library",
"//crypto/rand:go_default_library",
@@ -138,6 +139,7 @@ go_test(
"@org_golang_google_grpc//:go_default_library",
"@org_golang_google_grpc//codes:go_default_library",
"@org_golang_google_grpc//metadata:go_default_library",
"@org_golang_google_protobuf//types/known/emptypb:go_default_library",
"@org_golang_google_protobuf//types/known/timestamppb:go_default_library",
],
)

View File

@@ -205,7 +205,6 @@ func TestServer_VoluntaryExit(t *testing.T) {
mockNodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Times(2).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)
mockValidatorClient.EXPECT().

View File

@@ -3,6 +3,7 @@ load("@prysm//tools/go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"custom_hooks.go",
"endpoint_factory.go",
"structs.go",
],
@@ -16,11 +17,17 @@ go_library(
go_test(
name = "go_default_test",
srcs = ["structs_test.go"],
srcs = [
"custom_hooks_test.go",
"structs_test.go",
],
embed = [":go_default_library"],
deps = [
"//api/gateway/apimiddleware:go_default_library",
"//config/fieldparams:go_default_library",
"//proto/eth/service:go_default_library",
"//testing/assert:go_default_library",
"//testing/require:go_default_library",
"@com_github_pkg_errors//:go_default_library",
],
)

View File

@@ -0,0 +1,39 @@
package apimiddleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"github.com/prysmaticlabs/prysm/v4/api/gateway/apimiddleware"
)
// "/eth/v1/validator/{pubkey}/voluntary_exit" POST expects epoch as a query param.
// This hook adds the query param to the body so that it is a valid POST request as
// grpc-gateway does not handle query params in POST requests.
func setVoluntaryExitEpoch(
endpoint *apimiddleware.Endpoint,
_ http.ResponseWriter,
req *http.Request,
) (apimiddleware.RunDefault, apimiddleware.ErrorJson) {
if _, ok := endpoint.PostRequest.(*SetVoluntaryExitRequestJson); ok {
var epoch = req.URL.Query().Get("epoch")
// To handle the request without the query param
if epoch == "" {
epoch = "0"
}
_, err := strconv.ParseUint(epoch, 10, 64)
if err != nil {
return false, apimiddleware.InternalServerErrorWithMessage(err, "invalid epoch")
}
j := &SetVoluntaryExitRequestJson{Epoch: epoch}
b, err := json.Marshal(j)
if err != nil {
return false, apimiddleware.InternalServerErrorWithMessage(err, "could not marshal epoch")
}
req.Body = io.NopCloser(bytes.NewReader(b))
}
return true, nil
}

View File

@@ -0,0 +1,49 @@
package apimiddleware
import (
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v4/api/gateway/apimiddleware"
"github.com/prysmaticlabs/prysm/v4/testing/assert"
"github.com/prysmaticlabs/prysm/v4/testing/require"
)
func TestSetVoluntaryExitEpoch(t *testing.T) {
t.Run("ok", func(t *testing.T) {
endpoint := &apimiddleware.Endpoint{
PostRequest: &SetVoluntaryExitRequestJson{},
}
epoch := "300"
var body bytes.Buffer
request := httptest.NewRequest("POST", fmt.Sprintf("http://foo.example?epoch=%s", epoch), &body)
runDefault, errJson := setVoluntaryExitEpoch(endpoint, nil, request)
require.Equal(t, true, errJson == nil)
assert.Equal(t, apimiddleware.RunDefault(true), runDefault)
var b SetVoluntaryExitRequestJson
err := json.NewDecoder(request.Body).Decode(&b)
require.NoError(t, err)
require.Equal(t, epoch, b.Epoch)
})
t.Run("invalid query returns error", func(t *testing.T) {
endpoint := &apimiddleware.Endpoint{
PostRequest: &SetVoluntaryExitRequestJson{},
}
epoch := "/12"
var body bytes.Buffer
request := httptest.NewRequest("POST", fmt.Sprintf("http://foo.example?epoch=%s", epoch), &body)
runDefault, errJson := setVoluntaryExitEpoch(endpoint, nil, request)
assert.NotNil(t, errJson)
assert.Equal(t, apimiddleware.RunDefault(false), runDefault)
err := errors.New(errJson.Msg())
assert.ErrorContains(t, "invalid epoch", err)
})
}

View File

@@ -20,6 +20,7 @@ func (*ValidatorEndpointFactory) Paths() []string {
"/eth/v1/remotekeys",
"/eth/v1/validator/{pubkey}/feerecipient",
"/eth/v1/validator/{pubkey}/gas_limit",
"/eth/v1/validator/{pubkey}/voluntary_exit",
}
}
@@ -47,6 +48,12 @@ func (*ValidatorEndpointFactory) Create(path string) (*apimiddleware.Endpoint, e
endpoint.GetResponse = &GetGasLimitResponseJson{}
endpoint.PostRequest = &SetGasLimitRequestJson{}
endpoint.DeleteRequest = &DeleteGasLimitRequestJson{}
case "/eth/v1/validator/{pubkey}/voluntary_exit":
endpoint.PostRequest = &SetVoluntaryExitRequestJson{}
endpoint.PostResponse = &SetVoluntaryExitResponseJson{}
endpoint.Hooks = apimiddleware.HookCollection{
OnPreDeserializeRequestBodyIntoContainer: setVoluntaryExitEpoch,
}
default:
return nil, errors.New("invalid path")
}

View File

@@ -100,3 +100,22 @@ type SetGasLimitRequestJson struct {
type DeleteGasLimitRequestJson struct {
Pubkey string `json:"pubkey" hex:"true"`
}
type SetVoluntaryExitRequestJson struct {
Pubkey string `json:"pubkey" hex:"true"`
Epoch string `json:"epoch"`
}
type SetVoluntaryExitResponseJson struct {
SignedVoluntaryExit *SignedVoluntaryExitJson `json:"data"`
}
type SignedVoluntaryExitJson struct {
VoluntaryExit *VoluntaryExitJson `json:"message"`
Signature string `json:"signature" hex:"true"`
}
type VoluntaryExitJson struct {
Epoch string `json:"epoch"`
ValidatorIndex string `json:"validator_index"`
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/prysmaticlabs/prysm/v4/encoding/bytesutil"
ethpbservice "github.com/prysmaticlabs/prysm/v4/proto/eth/service"
eth "github.com/prysmaticlabs/prysm/v4/proto/prysm/v1alpha1"
"github.com/prysmaticlabs/prysm/v4/validator/client"
"github.com/prysmaticlabs/prysm/v4/validator/keymanager"
"github.com/prysmaticlabs/prysm/v4/validator/keymanager/derived"
slashingprotection "github.com/prysmaticlabs/prysm/v4/validator/slashing-protection-history"
@@ -24,6 +25,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)
// ListKeystores implements the standard validator key management API.
@@ -679,3 +681,51 @@ func validatePublicKey(pubkey []byte) error {
}
return nil
}
// SetVoluntaryExit creates a signed voluntary exit message and returns a VoluntaryExit object.
func (s *Server) SetVoluntaryExit(ctx context.Context, req *ethpbservice.SetVoluntaryExitRequest) (*ethpbservice.SetVoluntaryExitResponse, error) {
if s.validatorService == nil {
return nil, status.Error(codes.FailedPrecondition, "Validator service not ready")
}
if err := validatePublicKey(req.Pubkey); err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
if s.wallet == nil {
return nil, status.Error(codes.FailedPrecondition, "No wallet found")
}
km, err := s.validatorService.Keymanager()
if err != nil {
return nil, err
}
if req.Epoch == 0 {
genesisResponse, err := s.beaconNodeClient.GetGenesis(ctx, &emptypb.Empty{})
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not create voluntary exit: %v", err)
}
epoch, err := client.CurrentEpoch(genesisResponse.GenesisTime)
if err != nil {
return nil, status.Errorf(codes.Internal, "gRPC call to get genesis time failed: %v", err)
}
req.Epoch = epoch
}
sve, err := client.CreateSignedVoluntaryExit(
ctx,
s.beaconNodeValidatorClient,
km.Sign,
req.Pubkey,
req.Epoch,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "Could not create voluntary exit: %v", err)
}
return &ethpbservice.SetVoluntaryExitResponse{
Data: &ethpbservice.SetVoluntaryExitResponse_SignedVoluntaryExit{
Message: &ethpbservice.SetVoluntaryExitResponse_SignedVoluntaryExit_VoluntaryExit{
Epoch: uint64(sve.Exit.Epoch),
ValidatorIndex: uint64(sve.Exit.ValidatorIndex),
},
Signature: sve.Signature,
},
}, nil
}

View File

@@ -1,12 +1,14 @@
package rpc
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
@@ -17,6 +19,7 @@ import (
fieldparams "github.com/prysmaticlabs/prysm/v4/config/fieldparams"
"github.com/prysmaticlabs/prysm/v4/config/params"
validatorserviceconfig "github.com/prysmaticlabs/prysm/v4/config/validator/service"
"github.com/prysmaticlabs/prysm/v4/consensus-types/primitives"
"github.com/prysmaticlabs/prysm/v4/consensus-types/validator"
"github.com/prysmaticlabs/prysm/v4/crypto/bls"
"github.com/prysmaticlabs/prysm/v4/encoding/bytesutil"
@@ -41,6 +44,8 @@ import (
keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestServer_ListKeystores(t *testing.T) {
@@ -1540,3 +1545,116 @@ func TestServer_DeleteGasLimit(t *testing.T) {
})
}
}
func TestServer_SetVoluntaryExit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := grpc.NewContextWithServerTransportStream(context.Background(), &runtime.ServerTransportStream{})
defaultWalletPath = setupWalletDir(t)
opts := []accounts.Option{
accounts.WithWalletDir(defaultWalletPath),
accounts.WithKeymanagerType(keymanager.Derived),
accounts.WithWalletPassword(strongPass),
accounts.WithSkipMnemonicConfirm(true),
}
acc, err := accounts.NewCLIManager(opts...)
require.NoError(t, err)
w, err := acc.WalletCreate(ctx)
require.NoError(t, err)
km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false})
require.NoError(t, err)
m := &mock.MockValidator{Km: km}
vs, err := client.NewValidatorService(ctx, &client.Config{
Validator: m,
})
require.NoError(t, err)
dr, ok := km.(*derived.Keymanager)
require.Equal(t, true, ok)
err = dr.RecoverAccountsFromMnemonic(ctx, mocks.TestMnemonic, derived.DefaultMnemonicLanguage, "", 1)
require.NoError(t, err)
pubKeys, err := dr.FetchValidatingPublicKeys(ctx)
require.NoError(t, err)
beaconClient := validatormock.NewMockValidatorClient(ctrl)
mockNodeClient := validatormock.NewMockNodeClient(ctrl)
// Any time in the past will suffice
genesisTime := &timestamppb.Timestamp{
Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
}
beaconClient.EXPECT().ValidatorIndex(gomock.Any(), &eth.ValidatorIndexRequest{PublicKey: pubKeys[0][:]}).
Times(3).
Return(&eth.ValidatorIndexResponse{Index: 2}, nil)
beaconClient.EXPECT().DomainData(
gomock.Any(), // ctx
gomock.Any(), // epoch
).Times(3).
Return(&eth.DomainResponse{SignatureDomain: make([]byte, 32)}, nil /*err*/)
mockNodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Times(3).
Return(&eth.Genesis{GenesisTime: genesisTime}, nil)
s := &Server{
validatorService: vs,
beaconNodeValidatorClient: beaconClient,
wallet: w,
beaconNodeClient: mockNodeClient,
}
type want struct {
epoch primitives.Epoch
validatorIndex uint64
signature []byte
}
tests := []struct {
name string
pubkey []byte
epoch primitives.Epoch
w want
}{
{
name: "Ok: with epoch",
epoch: 30000000,
w: want{
epoch: 30000000,
validatorIndex: 2,
signature: []uint8{175, 157, 5, 134, 253, 2, 193, 35, 176, 43, 217, 36, 39, 240, 24, 79, 207, 133, 150, 7, 237, 16, 54, 244, 64, 27, 244, 17, 8, 225, 140, 1, 172, 24, 35, 95, 178, 116, 172, 213, 113, 182, 193, 61, 192, 65, 162, 253, 19, 202, 111, 164, 195, 215, 0, 205, 95, 7, 30, 251, 244, 157, 210, 155, 238, 30, 35, 219, 177, 232, 174, 62, 218, 69, 23, 249, 180, 140, 60, 29, 190, 249, 229, 95, 235, 236, 81, 33, 60, 4, 201, 227, 70, 239, 167, 2},
},
},
{
name: "Ok: epoch not set",
w: want{
epoch: 0,
validatorIndex: 2,
signature: []uint8{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := s.SetVoluntaryExit(ctx, &ethpbservice.SetVoluntaryExitRequest{Pubkey: pubKeys[0][:], Epoch: tt.epoch})
require.NoError(t, err)
if tt.w.epoch == 0 {
genesisResponse, err := s.beaconNodeClient.GetGenesis(ctx, &emptypb.Empty{})
require.NoError(t, err)
tt.w.epoch, err = client.CurrentEpoch(genesisResponse.GenesisTime)
require.NoError(t, err)
resp2, err := s.SetVoluntaryExit(ctx, &ethpbservice.SetVoluntaryExitRequest{Pubkey: pubKeys[0][:], Epoch: tt.epoch})
require.NoError(t, err)
tt.w.signature = resp2.Data.Signature
}
require.Equal(t, uint64(tt.w.epoch), resp.Data.Message.Epoch)
require.Equal(t, tt.w.validatorIndex, resp.Data.Message.ValidatorIndex)
require.NotEmpty(t, resp.Data.Signature)
ok = bytes.Equal(tt.w.signature, resp.Data.Signature)
require.Equal(t, true, ok)
})
}
}