Skip to content

Commit

Permalink
prysmctl: Add support for writing signed validator exits to disk (#12262
Browse files Browse the repository at this point in the history
)

* prysmctl: Add support for writing signed validator exits to disk

* Add dir suffix

* Add test to ensure no broadcast call was made

---------

Co-authored-by: prylabs-bulldozer[bot] <58059840+prylabs-bulldozer[bot]@users.noreply.github.com>
  • Loading branch information
prestonvanloon and prylabs-bulldozer[bot] authored Apr 17, 2023
1 parent 10b438e commit 0c7292b
Show file tree
Hide file tree
Showing 21 changed files with 236 additions and 29 deletions.
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{
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

0 comments on commit 0c7292b

Please sign in to comment.