diff --git a/validator/accounts/accounts_exit.go b/validator/accounts/accounts_exit.go index ab63bc8d483b..6827f3087b8c 100644 --- a/validator/accounts/accounts_exit.go +++ b/validator/accounts/accounts_exit.go @@ -99,54 +99,64 @@ func interact( r io.Reader, validatingPublicKeys [][48]byte, ) (rawPubKeys [][]byte, formattedPubKeys []string, err error) { - // Allow the user to interactively select the accounts to exit or optionally - // provide them via cli flags as a string of comma-separated, hex strings. - filteredPubKeys, err := filterPublicKeysFromUserInput( - cliCtx, - flags.VoluntaryExitPublicKeysFlag, - validatingPublicKeys, - prompt.SelectAccountsVoluntaryExitPromptText, - ) - if err != nil { - return nil, nil, errors.Wrap(err, "could not filter public keys for voluntary exit") - } - rawPubKeys = make([][]byte, len(filteredPubKeys)) - formattedPubKeys = make([]string, len(filteredPubKeys)) - for i, pk := range filteredPubKeys { - pubKeyBytes := pk.Marshal() - rawPubKeys[i] = pubKeyBytes - formattedPubKeys[i] = fmt.Sprintf("%#x", bytesutil.Trunc(pubKeyBytes)) - } - allAccountStr := strings.Join(formattedPubKeys, ", ") - if !cliCtx.IsSet(flags.VoluntaryExitPublicKeysFlag.Name) { - if len(filteredPubKeys) == 1 { - promptText := "Are you sure you want to perform a voluntary exit on 1 account? (%s) Y/N" - resp, err := promptutil.ValidatePrompt( - r, fmt.Sprintf(promptText, au.BrightGreen(formattedPubKeys[0])), promptutil.ValidateYesOrNo, - ) - if err != nil { - return nil, nil, err - } - if strings.EqualFold(resp, "n") { - return nil, nil, nil - } - } else { - promptText := "Are you sure you want to perform a voluntary exit on %d accounts? (%s) Y/N" - if len(filteredPubKeys) == len(validatingPublicKeys) { - promptText = fmt.Sprintf( - "Are you sure you want to perform a voluntary exit on all accounts? Y/N (%s)", - au.BrightGreen(allAccountStr)) + if !cliCtx.IsSet(flags.ExitAllFlag.Name) { + // Allow the user to interactively select the accounts to exit or optionally + // provide them via cli flags as a string of comma-separated, hex strings. + filteredPubKeys, err := filterPublicKeysFromUserInput( + cliCtx, + flags.VoluntaryExitPublicKeysFlag, + validatingPublicKeys, + prompt.SelectAccountsVoluntaryExitPromptText, + ) + if err != nil { + return nil, nil, errors.Wrap(err, "could not filter public keys for voluntary exit") + } + rawPubKeys = make([][]byte, len(filteredPubKeys)) + formattedPubKeys = make([]string, len(filteredPubKeys)) + for i, pk := range filteredPubKeys { + pubKeyBytes := pk.Marshal() + rawPubKeys[i] = pubKeyBytes + formattedPubKeys[i] = fmt.Sprintf("%#x", bytesutil.Trunc(pubKeyBytes)) + } + allAccountStr := strings.Join(formattedPubKeys, ", ") + if !cliCtx.IsSet(flags.VoluntaryExitPublicKeysFlag.Name) { + if len(filteredPubKeys) == 1 { + promptText := "Are you sure you want to perform a voluntary exit on 1 account? (%s) Y/N" + resp, err := promptutil.ValidatePrompt( + r, fmt.Sprintf(promptText, au.BrightGreen(formattedPubKeys[0])), promptutil.ValidateYesOrNo, + ) + if err != nil { + return nil, nil, err + } + if strings.EqualFold(resp, "n") { + return nil, nil, nil + } } else { - promptText = fmt.Sprintf(promptText, len(filteredPubKeys), au.BrightGreen(allAccountStr)) - } - resp, err := promptutil.ValidatePrompt(r, promptText, promptutil.ValidateYesOrNo) - if err != nil { - return nil, nil, err - } - if strings.EqualFold(resp, "n") { - return nil, nil, nil + promptText := "Are you sure you want to perform a voluntary exit on %d accounts? (%s) Y/N" + if len(filteredPubKeys) == len(validatingPublicKeys) { + promptText = fmt.Sprintf( + "Are you sure you want to perform a voluntary exit on all accounts? Y/N (%s)", + au.BrightGreen(allAccountStr)) + } else { + promptText = fmt.Sprintf(promptText, len(filteredPubKeys), au.BrightGreen(allAccountStr)) + } + resp, err := promptutil.ValidatePrompt(r, promptText, promptutil.ValidateYesOrNo) + if err != nil { + return nil, nil, err + } + if strings.EqualFold(resp, "n") { + return nil, nil, nil + } } } + } else { + rawPubKeys = make([][]byte, len(validatingPublicKeys)) + formattedPubKeys = make([]string, len(validatingPublicKeys)) + for i, pk := range validatingPublicKeys { + rawPubKeys[i] = pk[:] + formattedPubKeys[i] = fmt.Sprintf("%#x", bytesutil.Trunc(pk[:])) + } + fmt.Printf("About to perform a voluntary exit of %d accounts\n", len(rawPubKeys)) } promptHeader := au.Red("===============IMPORTANT===============") diff --git a/validator/accounts/accounts_exit_test.go b/validator/accounts/accounts_exit_test.go index ccb383310b69..34e34d3cb715 100644 --- a/validator/accounts/accounts_exit_test.go +++ b/validator/accounts/accounts_exit_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "sort" "testing" "time" @@ -20,7 +21,7 @@ import ( "google.golang.org/grpc/metadata" ) -func TestExitAccountsCli_Ok(t *testing.T) { +func TestExitAccountsCli_OK(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockValidatorClient := mock.NewMockBeaconNodeValidatorClient(ctrl) @@ -106,6 +107,107 @@ func TestExitAccountsCli_Ok(t *testing.T) { assert.Equal(t, "0x"+keystore.Pubkey[:12], formattedExitedKeys[0]) } +func TestExitAccountsCli_OK_AllPublicKeys(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockValidatorClient := mock.NewMockBeaconNodeValidatorClient(ctrl) + mockNodeClient := mock.NewMockNodeClient(ctrl) + + mockValidatorClient.EXPECT(). + ValidatorIndex(gomock.Any(), gomock.Any()). + Return(ðpb.ValidatorIndexResponse{Index: 0}, nil) + + mockValidatorClient.EXPECT(). + ValidatorIndex(gomock.Any(), gomock.Any()). + Return(ðpb.ValidatorIndexResponse{Index: 1}, nil) + + // Any time in the past will suffice + genesisTime := &types.Timestamp{ + Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + } + + mockNodeClient.EXPECT(). + GetGenesis(gomock.Any(), gomock.Any()). + Times(2). + Return(ðpb.Genesis{GenesisTime: genesisTime}, nil) + + mockValidatorClient.EXPECT(). + DomainData(gomock.Any(), gomock.Any()). + Times(2). + Return(ðpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil) + + mockValidatorClient.EXPECT(). + ProposeExit(gomock.Any(), gomock.AssignableToTypeOf(ðpb.SignedVoluntaryExit{})). + Times(2). + Return(ðpb.ProposeExitResponse{}, 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. + keystore1, _ := createKeystore(t, keysDir) + time.Sleep(time.Second) + keystore2, _ := createKeystore(t, keysDir) + time.Sleep(time.Second) + + // We initialize a wallet with a imported keymanager. + cliCtx := setupWalletCtx(t, &testWalletConfig{ + // Wallet configuration flags. + walletDir: walletDir, + keymanagerKind: keymanager.Imported, + walletPasswordFile: passwordFilePath, + accountPasswordFile: passwordFilePath, + // Flag required for ImportAccounts to work. + keysDir: keysDir, + // Exit all public keys. + exitAll: true, + }) + _, err := CreateWalletWithKeymanager(cliCtx.Context, &CreateWalletConfig{ + WalletCfg: &wallet.Config{ + WalletDir: walletDir, + KeymanagerKind: keymanager.Imported, + WalletPassword: password, + }, + }) + require.NoError(t, err) + require.NoError(t, ImportAccountsCli(cliCtx)) + + validatingPublicKeys, keymanager, err := prepareWallet(cliCtx) + require.NoError(t, err) + require.NotNil(t, validatingPublicKeys) + require.NotNil(t, keymanager) + + // Prepare user input for final confirmation step + var stdin bytes.Buffer + stdin.Write([]byte(exitPassphrase)) + rawPubKeys, formattedPubKeys, err := interact(cliCtx, &stdin, validatingPublicKeys) + require.NoError(t, err) + require.NotNil(t, rawPubKeys) + require.NotNil(t, formattedPubKeys) + + cfg := performExitCfg{ + mockValidatorClient, + mockNodeClient, + keymanager, + rawPubKeys, + formattedPubKeys, + } + rawExitedKeys, formattedExitedKeys, err := performExit(cliCtx, cfg) + require.NoError(t, err) + require.Equal(t, 2, len(rawExitedKeys)) + assert.DeepEqual(t, rawPubKeys, rawExitedKeys) + require.Equal(t, 2, len(formattedExitedKeys)) + wantedFormatted := []string{ + "0x" + keystore1.Pubkey[:12], + "0x" + keystore2.Pubkey[:12], + } + sort.Strings(wantedFormatted) + sort.Strings(formattedExitedKeys) + require.DeepEqual(t, wantedFormatted, formattedExitedKeys) +} + func TestPrepareWallet_EmptyWalletReturnsError(t *testing.T) { imported.ResetCaches() walletDir, _, passwordFilePath := setupWalletAndPasswordsDir(t) diff --git a/validator/accounts/cmd_accounts.go b/validator/accounts/cmd_accounts.go index f6bd016ebda8..b0c037ba7d88 100644 --- a/validator/accounts/cmd_accounts.go +++ b/validator/accounts/cmd_accounts.go @@ -142,6 +142,7 @@ var AccountCommands = &cli.Command{ flags.GrpcHeadersFlag, flags.GrpcRetriesFlag, flags.GrpcRetryDelayFlag, + flags.ExitAllFlag, featureconfig.Mainnet, featureconfig.PyrmontTestnet, featureconfig.ToledoTestnet, diff --git a/validator/accounts/wallet_create_test.go b/validator/accounts/wallet_create_test.go index fba2cc3bda2d..e7888df57f14 100644 --- a/validator/accounts/wallet_create_test.go +++ b/validator/accounts/wallet_create_test.go @@ -37,23 +37,24 @@ func init() { } type testWalletConfig struct { - walletDir string - passwordsDir string - backupDir string - keysDir string - deletePublicKeys string - enablePublicKeys string - disablePublicKeys string - voluntaryExitPublicKeys string - backupPublicKeys string - backupPasswordFile string - walletPasswordFile string - accountPasswordFile string - privateKeyFile string - grpcHeaders string + exitAll bool skipDepositConfirm bool - numAccounts int64 keymanagerKind keymanager.Kind + numAccounts int64 + grpcHeaders string + privateKeyFile string + accountPasswordFile string + walletPasswordFile string + backupPasswordFile string + backupPublicKeys string + voluntaryExitPublicKeys string + disablePublicKeys string + enablePublicKeys string + deletePublicKeys string + keysDir string + backupDir string + passwordsDir string + walletDir string } func setupWalletCtx( @@ -77,6 +78,7 @@ func setupWalletCtx( set.Int64(flags.NumAccountsFlag.Name, cfg.numAccounts, "") set.Bool(flags.SkipDepositConfirmationFlag.Name, cfg.skipDepositConfirm, "") set.Bool(flags.SkipMnemonic25thWordCheckFlag.Name, true, "") + set.Bool(flags.ExitAllFlag.Name, cfg.exitAll, "") set.String(flags.GrpcHeadersFlag.Name, cfg.grpcHeaders, "") if cfg.privateKeyFile != "" { @@ -98,6 +100,7 @@ func setupWalletCtx( assert.NoError(tb, set.Set(flags.AccountPasswordFileFlag.Name, cfg.accountPasswordFile)) assert.NoError(tb, set.Set(flags.NumAccountsFlag.Name, strconv.Itoa(int(cfg.numAccounts)))) assert.NoError(tb, set.Set(flags.SkipDepositConfirmationFlag.Name, strconv.FormatBool(cfg.skipDepositConfirm))) + assert.NoError(tb, set.Set(flags.ExitAllFlag.Name, strconv.FormatBool(cfg.exitAll))) assert.NoError(tb, set.Set(flags.GrpcHeadersFlag.Name, cfg.grpcHeaders)) return cli.NewContext(&app, set, nil) } diff --git a/validator/flags/flags.go b/validator/flags/flags.go index a161c101d04a..5d5a63034651 100644 --- a/validator/flags/flags.go +++ b/validator/flags/flags.go @@ -214,6 +214,12 @@ var ( "a voluntary exit", Value: "", } + // ExitAllFlag allows stakers to select all validating keys for exit. This will still require the staker + // to confirm a prompt for this action given it is a dangerous one. + ExitAllFlag = &cli.BoolFlag{ + Name: "exit-all", + Usage: "Exit all validators. This will still require the staker to confirm a prompt for the action", + } // BackupPasswordFile for encrypting accounts a user wishes to back up. BackupPasswordFile = &cli.StringFlag{ Name: "backup-password-file",