Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prysmctl: Add support for writing signed validator exits to disk #12262

Merged
merged 7 commits into from
Apr 17, 2023
1 change: 1 addition & 0 deletions cmd/prysmctl/validator/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ var Commands = []*cli.Command{
flags.GrpcRetryDelayFlag,
flags.ExitAllFlag,
flags.ForceExitFlag,
flags.VoluntaryExitJSONOutputPath,
features.Mainnet,
features.PraterTestnet,
features.SepoliaTestnet,
Expand Down
1 change: 1 addition & 0 deletions cmd/validator/accounts/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ go_test(
],
embed = [":go_default_library"],
deps = [
"//build/bazel:go_default_library",
"//cmd/validator/flags:go_default_library",
"//config/params:go_default_library",
"//crypto/bls:go_default_library",
Expand Down
1 change: 1 addition & 0 deletions cmd/validator/accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ var Commands = &cli.Command{
flags.GrpcRetryDelayFlag,
flags.ExitAllFlag,
flags.ForceExitFlag,
flags.VoluntaryExitJSONOutputPath,
features.Mainnet,
features.PraterTestnet,
features.SepoliaTestnet,
Expand Down
1 change: 1 addition & 0 deletions cmd/validator/accounts/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func AccountsExit(c *cli.Context, r io.Reader) error {
accounts.WithBeaconRPCProvider(beaconRPCProvider),
accounts.WithBeaconRESTApiProvider(c.String(flags.BeaconRESTApiProviderFlag.Name)),
accounts.WithGRPCHeaders(grpcHeaders),
accounts.WithExitJSONOutputPath(c.String(flags.VoluntaryExitJSONOutputPath.Name)),
}
// Get full set of public keys from the keymanager.
validatingPublicKeys, err := km.FetchValidatingPublicKeys(c.Context)
Expand Down
93 changes: 93 additions & 0 deletions cmd/validator/accounts/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package accounts
import (
"bytes"
"os"
"path"
"path/filepath"
"sort"
"testing"
"time"

"github.com/golang/mock/gomock"
"github.com/prysmaticlabs/prysm/v4/build/bazel"
"github.com/prysmaticlabs/prysm/v4/io/file"
ethpb "github.com/prysmaticlabs/prysm/v4/proto/prysm/v1alpha1"
"github.com/prysmaticlabs/prysm/v4/testing/assert"
"github.com/prysmaticlabs/prysm/v4/testing/require"
Expand Down Expand Up @@ -305,3 +308,93 @@ func TestExitAccountsCli_OK_ForceExit(t *testing.T) {
require.Equal(t, 1, len(formattedExitedKeys))
assert.Equal(t, "0x"+keystore.Pubkey[:12], formattedExitedKeys[0])
}

func TestExitAccountsCli_WriteJSON_NoBroadcast(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockValidatorClient := validatormock.NewMockValidatorClient(ctrl)
mockNodeClient := validatormock.NewMockNodeClient(ctrl)

mockValidatorClient.EXPECT().
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(),
}

mockNodeClient.EXPECT().
GetGenesis(gomock.Any(), gomock.Any()).
Return(&ethpb.Genesis{GenesisTime: genesisTime}, nil)

mockValidatorClient.EXPECT().
DomainData(gomock.Any(), gomock.Any()).
Return(&ethpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil)

walletDir, _, passwordFilePath := setupWalletAndPasswordsDir(t)
// Write a directory where we will import keys from.
keysDir := filepath.Join(t.TempDir(), "keysDir")
require.NoError(t, os.MkdirAll(keysDir, os.ModePerm))

// Create keystore file in the keys directory we can then import from in our wallet.
keystore, _ := createKeystore(t, keysDir)
time.Sleep(time.Second)

// We initialize a wallet with a local keymanager.
cliCtx := setupWalletCtx(t, &testWalletConfig{
// Wallet configuration flags.
walletDir: walletDir,
keymanagerKind: keymanager.Local,
walletPasswordFile: passwordFilePath,
accountPasswordFile: passwordFilePath,
// Flag required for ImportAccounts to work.
keysDir: keysDir,
// Flag required for ExitAccounts to work.
voluntaryExitPublicKeys: keystore.Pubkey,
})
opts := []accounts.Option{
accounts.WithWalletDir(walletDir),
accounts.WithKeymanagerType(keymanager.Local),
accounts.WithWalletPassword(password),
}
acc, err := accounts.NewCLIManager(opts...)
require.NoError(t, err)
_, err = acc.WalletCreate(cliCtx.Context)
require.NoError(t, err)
require.NoError(t, accountsImport(cliCtx))

_, km, err := walletWithKeymanager(cliCtx)
require.NoError(t, err)
require.NotNil(t, km)

validatingPublicKeys, err := km.FetchValidatingPublicKeys(cliCtx.Context)
require.NoError(t, err)
require.NotNil(t, validatingPublicKeys)

rawPubKeys, formattedPubKeys, err := accounts.FilterExitAccountsFromUserInput(
cliCtx, &bytes.Buffer{}, validatingPublicKeys, true,
)
require.NoError(t, err)
require.NotNil(t, rawPubKeys)
require.NotNil(t, formattedPubKeys)

out := path.Join(bazel.TestTmpDir(), "exits")

cfg := accounts.PerformExitCfg{
ValidatorClient: mockValidatorClient,
NodeClient: mockNodeClient,
Keymanager: km,
RawPubKeys: rawPubKeys,
FormattedPubKeys: formattedPubKeys,
OutputDirectory: out,
}
rawExitedKeys, formattedExitedKeys, err := accounts.PerformVoluntaryExit(cliCtx.Context, cfg)
require.NoError(t, err)
require.Equal(t, 1, len(rawExitedKeys))
assert.DeepEqual(t, rawPubKeys[0], rawExitedKeys[0])
require.Equal(t, 1, len(formattedExitedKeys))
assert.Equal(t, "0x"+keystore.Pubkey[:12], formattedExitedKeys[0])

require.Equal(t, true, file.FileExists(path.Join(out, "validator-exit-1.json")), "Expected file to exist")
}
6 changes: 6 additions & 0 deletions cmd/validator/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ var (
Name: "force-exit",
Usage: "Exit without displaying the confirmation prompt",
}
VoluntaryExitJSONOutputPath = &cli.StringFlag{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to add this to the usage.go file I believe otherwise it won't show up in help

Copy link
Member Author

@prestonvanloon prestonvanloon Apr 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It only applies to subcommands in prysmctl and validator. I put this flag everywhere that ForceExit appears

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot from 2023-04-17 11-54-46

Name: "exit-json-output-dir",
Usage: "The output directory to write voluntary exits as individual unencrypted JSON " +
"files. If this flag is provided, voluntary exits will be written to the provided " +
"directory and will not be broadcasted.",
}
// BackupPasswordFile for encrypting accounts a user wishes to back up.
BackupPasswordFile = &cli.StringFlag{
Name: "backup-password-file",
Expand Down
4 changes: 4 additions & 0 deletions validator/accounts/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ go_library(
"//validator/accounts/userprompt:go_default_library",
"//validator/accounts/wallet:go_default_library",
"//validator/client:go_default_library",
"//validator/client/beacon-api:go_default_library",
"//validator/client/iface:go_default_library",
"//validator/client/node-client-factory:go_default_library",
"//validator/client/validator-client-factory:go_default_library",
Expand Down Expand Up @@ -72,12 +73,15 @@ go_test(
data = glob(["testdata/**"]),
embed = [":go_default_library"],
deps = [
"//beacon-chain/rpc/apimiddleware:go_default_library",
"//build/bazel:go_default_library",
"//cmd/validator/flags:go_default_library",
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",
"//consensus-types/primitives:go_default_library",
"//crypto/bls:go_default_library",
"//encoding/bytesutil:go_default_library",
"//io/file:go_default_library",
"//proto/eth/service:go_default_library",
"//proto/prysm/v1alpha1:go_default_library",
"//testing/assert:go_default_library",
Expand Down
46 changes: 45 additions & 1 deletion validator/accounts/accounts_exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package accounts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"path"
"strings"

"github.com/ethereum/go-ethereum/common/hexutil"
Expand All @@ -12,7 +14,10 @@ import (
fieldparams "github.com/prysmaticlabs/prysm/v4/config/fieldparams"
"github.com/prysmaticlabs/prysm/v4/config/params"
"github.com/prysmaticlabs/prysm/v4/encoding/bytesutil"
"github.com/prysmaticlabs/prysm/v4/io/file"
eth "github.com/prysmaticlabs/prysm/v4/proto/prysm/v1alpha1"
"github.com/prysmaticlabs/prysm/v4/validator/client"
beacon_api "github.com/prysmaticlabs/prysm/v4/validator/client/beacon-api"
"github.com/prysmaticlabs/prysm/v4/validator/client/iface"
"github.com/prysmaticlabs/prysm/v4/validator/keymanager"
"google.golang.org/protobuf/types/known/emptypb"
Expand All @@ -25,6 +30,7 @@ type PerformExitCfg struct {
Keymanager keymanager.IKeymanager
RawPubKeys [][]byte
FormattedPubKeys []string
OutputDirectory string
}

// ExitPassphrase exported for use in test.
Expand Down Expand Up @@ -62,6 +68,7 @@ func (acm *AccountsCLIManager) Exit(ctx context.Context) error {
acm.keymanager,
acm.rawPubKeys,
acm.formattedPubKeys,
acm.exitJSONOutputPath,
}
rawExitedKeys, trimmedExitedKeys, err := PerformVoluntaryExit(ctx, cfg)
if err != nil {
Expand All @@ -78,7 +85,23 @@ func PerformVoluntaryExit(
) (rawExitedKeys [][]byte, formattedExitedKeys []string, err error) {
var rawNotExitedKeys [][]byte
for i, key := range cfg.RawPubKeys {
if err := client.ProposeExit(ctx, cfg.ValidatorClient, cfg.NodeClient, cfg.Keymanager.Sign, key); err != nil {
// When output directory is present, only create the signed exit, but do not propose it.
// Otherwise, propose the exit immediately.
if len(cfg.OutputDirectory) > 0 {
sve, err := client.CreateSignedVoluntaryExit(ctx, cfg.ValidatorClient, cfg.NodeClient, cfg.Keymanager.Sign, key)
if err != nil {
rawNotExitedKeys = append(rawNotExitedKeys, key)
msg := err.Error()
if strings.Contains(msg, blocks.ValidatorAlreadyExitedMsg) ||
strings.Contains(msg, blocks.ValidatorCannotExitYetMsg) {
log.Warningf("Could not create voluntary exit for account %s: %s", cfg.FormattedPubKeys[i], msg)
} else {
log.WithError(err).Errorf("voluntary exit failed for account %s", cfg.FormattedPubKeys[i])
}
} 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 {
rawNotExitedKeys = append(rawNotExitedKeys, key)

msg := err.Error()
Expand Down Expand Up @@ -148,3 +171,24 @@ func displayExitInfo(rawExitedKeys [][]byte, trimmedExitedKeys []string) {
log.Info("No successful voluntary exits")
}
}

func writeSignedVoluntaryExitJSON(ctx context.Context, sve *eth.SignedVoluntaryExit, outputDirectory string) error {
if err := file.MkdirAll(outputDirectory); err != nil {
return err
}

jsve := beacon_api.JsonifySignedVoluntaryExits([]*eth.SignedVoluntaryExit{sve})[0]
b, err := json.Marshal(jsve)
if err != nil {
return errors.Wrap(err, "failed to marshal JSON signed voluntary exit")
}

filepath := path.Join(outputDirectory, fmt.Sprintf("validator-exit-%s.json", jsve.Exit.ValidatorIndex))
if err := file.WriteFile(filepath, b); err != nil {
return errors.Wrap(err, "failed to write validator exist json")
}

log.Infof("Wrote signed validator exit JSON to %s", filepath)

return nil
}
31 changes: 31 additions & 0 deletions validator/accounts/accounts_exit_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package accounts

import (
"context"
"encoding/json"
"fmt"
"path"
"testing"

"github.com/prysmaticlabs/prysm/v4/beacon-chain/rpc/apimiddleware"
"github.com/prysmaticlabs/prysm/v4/build/bazel"
fieldparams "github.com/prysmaticlabs/prysm/v4/config/fieldparams"
"github.com/prysmaticlabs/prysm/v4/encoding/bytesutil"
"github.com/prysmaticlabs/prysm/v4/io/file"
eth "github.com/prysmaticlabs/prysm/v4/proto/prysm/v1alpha1"
"github.com/prysmaticlabs/prysm/v4/testing/assert"
"github.com/prysmaticlabs/prysm/v4/testing/require"
"github.com/sirupsen/logrus/hooks/test"
Expand Down Expand Up @@ -34,3 +42,26 @@ func TestPrepareAllKeys(t *testing.T) {
assert.Equal(t, "0x6b6579310000", formatted[0])
assert.Equal(t, "0x6b6579320000", formatted[1])
}

func TestWriteSignedVoluntaryExitJSON(t *testing.T) {
sve := &eth.SignedVoluntaryExit{
Exit: &eth.VoluntaryExit{
Epoch: 5,
ValidatorIndex: 300,
},
Signature: []byte{0x01, 0x02},
}

output := path.Join(bazel.TestTmpDir(), "TestWriteSignedVoluntaryExitJSON")
require.NoError(t, writeSignedVoluntaryExitJSON(context.Background(), sve, output))

b, err := file.ReadFileAsBytes(path.Join(output, "validator-exit-300.json"))
require.NoError(t, err)

svej := &apimiddleware.SignedVoluntaryExitJson{}
require.NoError(t, json.Unmarshal(b, svej))

require.Equal(t, fmt.Sprintf("%d", sve.Exit.Epoch), svej.Exit.Epoch)
require.Equal(t, fmt.Sprintf("%d", sve.Exit.ValidatorIndex), svej.Exit.ValidatorIndex)
require.Equal(t, "0x0102", svej.Signature)
}
1 change: 1 addition & 0 deletions validator/accounts/cli_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type AccountsCLIManager struct {
filteredPubKeys []bls.PublicKey
rawPubKeys [][]byte
formattedPubKeys []string
exitJSONOutputPath string
walletDir string
walletPassword string
mnemonic string
Expand Down
7 changes: 7 additions & 0 deletions validator/accounts/cli_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ func WithFormattedPubKeys(formattedPubKeys []string) Option {
}
}

func WithExitJSONOutputPath(outputPath string) Option {
return func(acc *AccountsCLIManager) error {
acc.exitJSONOutputPath = outputPath
return nil
}
}

// WithWalletDir specifies the password for backups.
func WithWalletDir(walletDir string) Option {
return func(acc *AccountsCLIManager) error {
Expand Down
3 changes: 2 additions & 1 deletion validator/client/beacon-api/beacon_block_json_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ func jsonifyProposerSlashings(proposerSlashings []*ethpb.ProposerSlashing) []*ap
return jsonProposerSlashings
}

func jsonifySignedVoluntaryExits(voluntaryExits []*ethpb.SignedVoluntaryExit) []*apimiddleware.SignedVoluntaryExitJson {
// JsonifySignedVoluntaryExits converts an array of voluntary exit structs to a JSON hex string compatible format.
func JsonifySignedVoluntaryExits(voluntaryExits []*ethpb.SignedVoluntaryExit) []*apimiddleware.SignedVoluntaryExitJson {
jsonSignedVoluntaryExits := make([]*apimiddleware.SignedVoluntaryExitJson, len(voluntaryExits))
for index, signedVoluntaryExit := range voluntaryExits {
jsonSignedVoluntaryExit := &apimiddleware.SignedVoluntaryExitJson{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ func TestBeaconBlockJsonHelpers_JsonifySignedVoluntaryExits(t *testing.T) {
},
}

result := jsonifySignedVoluntaryExits(input)
result := JsonifySignedVoluntaryExits(input)
assert.DeepEqual(t, expectedResult, result)
}

Expand Down
Loading