diff --git a/cmd/prysmctl/signing/cmd.go b/cmd/prysmctl/signing/cmd.go index 089abba279..5d6781922a 100644 --- a/cmd/prysmctl/signing/cmd.go +++ b/cmd/prysmctl/signing/cmd.go @@ -36,6 +36,7 @@ var Commands = []*cli.Command{ flags.GrpcRetriesFlag, flags.GrpcRetryDelayFlag, flags.ExitAllFlag, + flags.ForceExitFlag, features.Mainnet, features.PraterTestnet, features.RopstenTestnet, diff --git a/cmd/validator/accounts/accounts.go b/cmd/validator/accounts/accounts.go index 7f9ee3aa28..6aff7166c2 100644 --- a/cmd/validator/accounts/accounts.go +++ b/cmd/validator/accounts/accounts.go @@ -169,6 +169,7 @@ var Commands = &cli.Command{ flags.GrpcRetriesFlag, flags.GrpcRetryDelayFlag, flags.ExitAllFlag, + flags.ForceExitFlag, features.Mainnet, features.PraterTestnet, features.RopstenTestnet, diff --git a/cmd/validator/accounts/exit.go b/cmd/validator/accounts/exit.go index 84d8d69d23..45c83ae6c6 100644 --- a/cmd/validator/accounts/exit.go +++ b/cmd/validator/accounts/exit.go @@ -92,7 +92,7 @@ func AccountsExit(c *cli.Context, r io.Reader) error { return errors.New("wallet is empty, no accounts to delete") } // Filter keys either from CLI flag or from interactive session. - rawPubKey, formattedPubKeys, err := accounts.FilterExitAccountsFromUserInput(c, r, validatingPublicKeys) + rawPubKey, formattedPubKeys, err := accounts.FilterExitAccountsFromUserInput(c, r, validatingPublicKeys, c.Bool(flags.ForceExitFlag.Name)) if err != nil { return errors.Wrap(err, "could not filter public keys for deletion") } diff --git a/cmd/validator/accounts/exit_test.go b/cmd/validator/accounts/exit_test.go index 25095d6d41..035b32fcb5 100644 --- a/cmd/validator/accounts/exit_test.go +++ b/cmd/validator/accounts/exit_test.go @@ -89,7 +89,7 @@ func TestExitAccountsCli_OK(t *testing.T) { var stdin bytes.Buffer stdin.Write([]byte(accounts.ExitPassphrase)) rawPubKeys, formattedPubKeys, err := accounts.FilterExitAccountsFromUserInput( - cliCtx, &stdin, validatingPublicKeys, + cliCtx, &stdin, validatingPublicKeys, false, ) require.NoError(t, err) require.NotNil(t, rawPubKeys) @@ -190,7 +190,7 @@ func TestExitAccountsCli_OK_AllPublicKeys(t *testing.T) { var stdin bytes.Buffer stdin.Write([]byte(accounts.ExitPassphrase)) rawPubKeys, formattedPubKeys, err := accounts.FilterExitAccountsFromUserInput( - cliCtx, &stdin, validatingPublicKeys, + cliCtx, &stdin, validatingPublicKeys, false, ) require.NoError(t, err) require.NotNil(t, rawPubKeys) @@ -216,3 +216,92 @@ func TestExitAccountsCli_OK_AllPublicKeys(t *testing.T) { sort.Strings(formattedExitedKeys) require.DeepEqual(t, wantedFormatted, formattedExitedKeys) } + +func TestExitAccountsCli_OK_ForceExit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockValidatorClient := mock2.NewMockBeaconNodeValidatorClient(ctrl) + mockNodeClient := mock2.NewMockNodeClient(ctrl) + + mockValidatorClient.EXPECT(). + ValidatorIndex(gomock.Any(), gomock.Any()). + Return(ðpb.ValidatorIndexResponse{Index: 1}, nil) + + // Any time in the past will suffice + genesisTime := ×tamppb.Timestamp{ + Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + } + + mockNodeClient.EXPECT(). + GetGenesis(gomock.Any(), gomock.Any()). + Return(ðpb.Genesis{GenesisTime: genesisTime}, nil) + + mockValidatorClient.EXPECT(). + DomainData(gomock.Any(), gomock.Any()). + Return(ðpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil) + + mockValidatorClient.EXPECT(). + ProposeExit(gomock.Any(), gomock.AssignableToTypeOf(ðpb.SignedVoluntaryExit{})). + 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. + 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) + + cfg := accounts.PerformExitCfg{ + ValidatorClient: mockValidatorClient, + NodeClient: mockNodeClient, + Keymanager: km, + RawPubKeys: rawPubKeys, + FormattedPubKeys: formattedPubKeys, + } + 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]) +} diff --git a/cmd/validator/flags/flags.go b/cmd/validator/flags/flags.go index f61ff1140f..95885c3809 100644 --- a/cmd/validator/flags/flags.go +++ b/cmd/validator/flags/flags.go @@ -219,6 +219,11 @@ var ( Name: "exit-all", Usage: "Exit all validators. This will still require the staker to confirm a userprompt for the action", } + // ForceExitFlag to exit without displaying the confirmation prompt. + ForceExitFlag = &cli.BoolFlag{ + Name: "force-exit", + Usage: "Exit without displaying the confirmation prompt", + } // BackupPasswordFile for encrypting accounts a user wishes to back up. BackupPasswordFile = &cli.StringFlag{ Name: "backup-password-file", diff --git a/validator/accounts/accounts_helper.go b/validator/accounts/accounts_helper.go index 9ad082d68e..a17fedd699 100644 --- a/validator/accounts/accounts_helper.go +++ b/validator/accounts/accounts_helper.go @@ -141,6 +141,7 @@ func FilterExitAccountsFromUserInput( cliCtx *cli.Context, r io.Reader, validatingPublicKeys [][fieldparams.BLSPubkeyLength]byte, + forceExit bool, ) (rawPubKeys [][]byte, formattedPubKeys []string, err error) { if !cliCtx.IsSet(flags.ExitAllFlag.Name) { // Allow the user to interactively select the accounts to exit or optionally @@ -197,8 +198,12 @@ func FilterExitAccountsFromUserInput( fmt.Printf("About to perform a voluntary exit of %d accounts\n", len(rawPubKeys)) } + if forceExit { + return rawPubKeys, formattedPubKeys, nil + } + promptHeader := au.Red("===============IMPORTANT===============") - promptDescription := "Withdrawing funds is not possible in Phase 0 of the system. " + + promptDescription := "Withdrawing funds is not yet possible. " + "Please navigate to the following website and make sure you understand the current implications " + "of a voluntary exit before making the final decision:" promptURL := au.Blue("https://docs.prylabs.network/docs/wallet/exiting-a-validator/#withdrawal-delay-warning")