From fdf456f398e1ce35bffb0e34328477ca5330119d Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Tue, 29 Oct 2024 12:14:06 +0800 Subject: [PATCH] Validator manager commands for the Keymanager APIs (#6261) * Validator manager commands for standard key-manager APIs * Merge latest unstable * Fix Some in lib.rs * Replace Arg::with_name with Arg::new * Update takes_value * Remove clap::App * Change App to Command * Add command in use * Remove generic in ArgMatches * Fix matches.get_flag * Fixes * fix error handling * SetTrue in import * Fix * Fix builder-proposal flag (will delete the flag later) * Minor fix * Fix prefer_builder_proposals * Remove unwrap * Error handling from Michael * Add cli help text * Use None in import to simplify * Delete unwrap * Revert flags option * Simplify help command code * Remove flag header in move * Merge remote-tracking branch 'origin/unstable' into pahor/validator-manager-standard-keystore * Add log in VC when keystore is deleted * Delete duplicated log when validator does not exist * Simplify log code * Rename remove to delete * cargo-fmt * Try to remove a function * make-cli * Error handling * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * Update CLI hel text * make-cli * Fix checks * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * Try to fix check errors * Fix test * Remove changes * Update flag name * CLI display order * Move builde_proposals flag * Add doc * mdlint * Update validator_manager/src/list_validators.rs Co-authored-by: Mac L * Delete empty line * Fix list * Simplify delete * Add support to delete more validators * Fix test * Rename response * Add (s) * Add test to delete multiple validators * Book and cli * Make cli * Only log when keystore is deleted * Revise deletion log * Add validator pubkey to error message * Merge import * Thank you Mac * Test * Add flags * Error handling for password * make cli * Merge remote-tracking branch 'origin/unstable' into vm * make cli * Fix test * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * Fix test * vm test * Debug trait thank you Michael * Fix test * Merge branch 'unstable' into vm * test * testing * Combine import validator(s) * make cli * Add requires * Update book * mdlint * Only show import log when import is successful * delete testing * Test for standard format * Test standard format * Test * fix builder_proposals flag * Fix test for standard format * Add requires * Fix vm test * make cli * Remove flag header * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * make cli * Delete space * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * Merge branch 'unstable' into vm * Rename delete_validator to delete_validators * Rearrange * Remove pub in run function * Fix grammar * Apply suggestions from code review Co-authored-by: Michael Sproul * Remove description * Merge branch 'vm' of https://github.com/chong-he/lighthouse into vm * Close bracket * make cli * Revise list code and test * Revise import flag * make cli * Comment out test * Update vm test * Simplify * Merge remote-tracking branch 'origin/unstable' into vm * make cli * Add test * Add password as a requirement for keystore file * Correct flags in docs * typo --- Cargo.lock | 1 + book/src/SUMMARY.md | 1 + book/src/help_vm.md | 5 + book/src/help_vm_import.md | 33 +- book/src/validator-manager-api.md | 39 +++ book/src/validator-manager-create.md | 2 + common/account_utils/src/lib.rs | 13 +- lighthouse/tests/validator_manager.rs | 80 ++++- validator_client/src/http_api/keystores.rs | 37 ++- validator_manager/Cargo.toml | 1 + validator_manager/src/create_validators.rs | 9 - validator_manager/src/delete_validators.rs | 293 +++++++++++++++++ validator_manager/src/import_validators.rs | 365 ++++++++++++++++++--- validator_manager/src/lib.rs | 14 +- validator_manager/src/list_validators.rs | 201 ++++++++++++ validator_manager/src/move_validators.rs | 10 - 16 files changed, 1031 insertions(+), 73 deletions(-) create mode 100644 book/src/validator-manager-api.md create mode 100644 validator_manager/src/delete_validators.rs create mode 100644 validator_manager/src/list_validators.rs diff --git a/Cargo.lock b/Cargo.lock index 18602ff8781..0d9da0c7fed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9222,6 +9222,7 @@ dependencies = [ "account_utils", "clap", "clap_utils", + "derivative", "environment", "eth2", "eth2_network_config", diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 86c97af0da1..c38ee58e3b0 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -15,6 +15,7 @@ * [The `validator-manager` Command](./validator-manager.md) * [Creating validators](./validator-manager-create.md) * [Moving validators](./validator-manager-move.md) + * [Managing validators](./validator-manager-api.md) * [Slashing Protection](./slashing-protection.md) * [Voluntary Exits](./voluntary-exit.md) * [Partial Withdrawals](./partial-withdrawal.md) diff --git a/book/src/help_vm.md b/book/src/help_vm.md index 9b6c5d4f3bd..50c204f371c 100644 --- a/book/src/help_vm.md +++ b/book/src/help_vm.md @@ -23,6 +23,11 @@ Commands: "create-validators" command. This command only supports validators signing via a keystore on the local file system (i.e., not Web3Signer validators). + list + Lists all validators in a validator client using the HTTP API. + delete + Deletes one or more validators from a validator client using the HTTP + API. help Print this message or the help of the given subcommand(s) diff --git a/book/src/help_vm_import.md b/book/src/help_vm_import.md index b4999d3fe31..68aab768aed 100644 --- a/book/src/help_vm_import.md +++ b/book/src/help_vm_import.md @@ -5,9 +5,17 @@ Uploads validators to a validator client using the HTTP API. The validators are defined in a JSON file which can be generated using the "create-validators" command. -Usage: lighthouse validator_manager import [OPTIONS] --validators-file +Usage: lighthouse validator_manager import [OPTIONS] Options: + --builder-boost-factor + When provided, the imported validator will use this percentage + multiplier to apply to the builder's payload value when choosing + between a builder payload header and payload from the local execution + node. + --builder-proposals + When provided, the imported validator will attempt to create blocks + via builder rather than the local EL. [possible values: true, false] -d, --datadir Used to specify a custom root data directory for lighthouse keys and databases. Defaults to $HOME/.lighthouse/{network} where network is @@ -17,6 +25,10 @@ Options: Specifies the verbosity level used when emitting logs to the terminal. [default: info] [possible values: info, debug, trace, warn, error, crit] + --gas-limit + When provided, the imported validator will use this gas limit. It is + recommended to leave this as the default value by not specifying this + flag. --genesis-state-url A URL of a beacon-API compatible server from which to download the genesis state. Checkpoint sync server URLs can generally be used with @@ -26,6 +38,10 @@ Options: --genesis-state-url-timeout The timeout in seconds for the request to --genesis-state-url. [default: 180] + --keystore-file + The path to a keystore JSON file to be imported to the validator + client. This file is usually created using staking-deposit-cli or + ethstaker-deposit-cli --log-format Specifies the log format used when emitting logs to the terminal. [possible values: JSON] @@ -50,6 +66,15 @@ Options: --network Name of the Eth2 chain Lighthouse will sync and follow. [possible values: mainnet, gnosis, chiado, sepolia, holesky] + --password + Password of the keystore file. + --prefer-builder-proposals + When provided, the imported validator will always prefer blocks + constructed by builders, regardless of payload value. [possible + values: true, false] + --suggested-fee-recipient + When provided, the imported validator will use the suggested fee + recipient. Omit this flag to use the default value from the VC. -t, --testnet-dir Path to directory containing eth2_testnet specs. Defaults to a hard-coded Lighthouse testnet. Only effective if there is no existing @@ -60,10 +85,8 @@ Options: --vc-token The file containing a token required by the validator client. --vc-url - A HTTP(S) address of a validator client using the keymanager-API. If - this value is not supplied then a 'dry run' will be conducted where no - changes are made to the validator client. [default: - http://localhost:5062] + A HTTP(S) address of a validator client using the keymanager-API. + [default: http://localhost:5062] Flags: --disable-log-timestamp diff --git a/book/src/validator-manager-api.md b/book/src/validator-manager-api.md new file mode 100644 index 00000000000..a5fc69fd5ad --- /dev/null +++ b/book/src/validator-manager-api.md @@ -0,0 +1,39 @@ +# Managing Validators + +The `lighthouse validator-manager` uses the [Keymanager API](https://ethereum.github.io/keymanager-APIs/#/) to list, import and delete keystores via the HTTP API. This requires the validator client running with the flag `--http`. + +## Delete + +The `delete` command deletes one or more validators from the validator client. It will also modify the `validator_definitions.yml` file automatically so there is no manual action required from the user after the delete. To `delete`: + +```bash +lighthouse vm delete --vc-token --validators pubkey1,pubkey2 +``` + +Example: + +```bash +lighthouse vm delete --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4,0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f +``` + +## Import + +The `import` command imports validator keystores generated by the staking-deposit-cli/ethstaker-deposit-cli. To import a validator keystore: + +```bash +lighthouse vm import --vc-token --keystore-file /path/to/json --password keystore_password +``` + +Example: + +``` +lighthouse vm import --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --keystore-file keystore.json --password keystore_password +``` + +## List + +To list the validators running on the validator client: + +```bash +lighthouse vm list --vc-token ~/.lighthouse/mainnet/validators/api-token.txt +``` diff --git a/book/src/validator-manager-create.md b/book/src/validator-manager-create.md index d97f953fc19..b4c86dc6da8 100644 --- a/book/src/validator-manager-create.md +++ b/book/src/validator-manager-create.md @@ -69,6 +69,8 @@ lighthouse \ > Be sure to remove `./validators.json` after the import is successful since it > contains unencrypted validator keystores. +> Note: To import validators with validator-manager using keystore files created using the staking deposit CLI, refer to [Managing Validators](./validator-manager-api.md#import). + ## Detailed Guide This guide will create two validators and import them to a VC. For simplicity, diff --git a/common/account_utils/src/lib.rs b/common/account_utils/src/lib.rs index 2c8bbbf4b4e..c1fa621abb1 100644 --- a/common/account_utils/src/lib.rs +++ b/common/account_utils/src/lib.rs @@ -9,13 +9,16 @@ use eth2_wallet::{ use filesystem::{create_with_600_perms, Error as FsError}; use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; -use std::fs::{self, File}; use std::io; use std::io::prelude::*; use std::path::{Path, PathBuf}; use std::str::from_utf8; use std::thread::sleep; use std::time::Duration; +use std::{ + fs::{self, File}, + str::FromStr, +}; use zeroize::Zeroize; pub mod validator_definitions; @@ -215,6 +218,14 @@ pub fn mnemonic_from_phrase(phrase: &str) -> Result { #[serde(transparent)] pub struct ZeroizeString(String); +impl FromStr for ZeroizeString { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_owned())) + } +} + impl From for ZeroizeString { fn from(s: String) -> Self { Self(s) diff --git a/lighthouse/tests/validator_manager.rs b/lighthouse/tests/validator_manager.rs index bca6a18ab56..999f3c31415 100644 --- a/lighthouse/tests/validator_manager.rs +++ b/lighthouse/tests/validator_manager.rs @@ -9,7 +9,9 @@ use tempfile::{tempdir, TempDir}; use types::*; use validator_manager::{ create_validators::CreateConfig, + delete_validators::DeleteConfig, import_validators::ImportConfig, + list_validators::ListConfig, move_validators::{MoveConfig, PasswordSource, Validators}, }; @@ -105,6 +107,18 @@ impl CommandLineTest { } } +impl CommandLineTest { + fn validators_list() -> Self { + Self::default().flag("list", None) + } +} + +impl CommandLineTest { + fn validators_delete() -> Self { + Self::default().flag("delete", None) + } +} + #[test] pub fn validator_create_without_output_path() { CommandLineTest::validators_create().assert_failed(); @@ -199,10 +213,18 @@ pub fn validator_import_defaults() { .flag("--vc-token", Some("./token.json")) .assert_success(|config| { let expected = ImportConfig { - validators_file_path: PathBuf::from("./vals.json"), + validators_file_path: Some(PathBuf::from("./vals.json")), + keystore_file_path: None, vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), vc_token_path: PathBuf::from("./token.json"), ignore_duplicates: false, + password: None, + fee_recipient: None, + builder_boost_factor: None, + gas_limit: None, + builder_proposals: None, + enabled: None, + prefer_builder_proposals: None, }; assert_eq!(expected, config); }); @@ -216,10 +238,18 @@ pub fn validator_import_misc_flags() { .flag("--ignore-duplicates", None) .assert_success(|config| { let expected = ImportConfig { - validators_file_path: PathBuf::from("./vals.json"), + validators_file_path: Some(PathBuf::from("./vals.json")), + keystore_file_path: None, vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), vc_token_path: PathBuf::from("./token.json"), ignore_duplicates: true, + password: None, + fee_recipient: None, + builder_boost_factor: None, + gas_limit: None, + builder_proposals: None, + enabled: None, + prefer_builder_proposals: None, }; assert_eq!(expected, config); }); @@ -233,7 +263,17 @@ pub fn validator_import_missing_token() { } #[test] -pub fn validator_import_missing_validators_file() { +pub fn validator_import_using_both_file_flags() { + CommandLineTest::validators_import() + .flag("--vc-token", Some("./token.json")) + .flag("--validators-file", Some("./vals.json")) + .flag("--keystore-file", Some("./keystore.json")) + .flag("--password", Some("abcd")) + .assert_failed(); +} + +#[test] +pub fn validator_import_missing_both_file_flags() { CommandLineTest::validators_import() .flag("--vc-token", Some("./token.json")) .assert_failed(); @@ -394,3 +434,37 @@ pub fn validator_move_count() { assert_eq!(expected, config); }); } + +#[test] +pub fn validator_list_defaults() { + CommandLineTest::validators_list() + .flag("--vc-token", Some("./token.json")) + .assert_success(|config| { + let expected = ListConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + }; + assert_eq!(expected, config); + }); +} + +#[test] +pub fn validator_delete_defaults() { + CommandLineTest::validators_delete() + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--vc-token", Some("./token.json")) + .assert_success(|config| { + let expected = DeleteConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + validators_to_delete: vec![ + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_0).unwrap(), + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_1).unwrap(), + ], + }; + assert_eq!(expected, config); + }); +} diff --git a/validator_client/src/http_api/keystores.rs b/validator_client/src/http_api/keystores.rs index 074c5783475..e5477ff8df7 100644 --- a/validator_client/src/http_api/keystores.rs +++ b/validator_client/src/http_api/keystores.rs @@ -75,12 +75,6 @@ pub fn import( ))); } - info!( - log, - "Importing keystores via standard HTTP API"; - "count" => request.keystores.len(), - ); - // Import slashing protection data before keystores, so that new keystores don't start signing // without it. Do not return early on failure, propagate the failure to each key. let slashing_protection_status = @@ -156,6 +150,19 @@ pub fn import( statuses.push(status); } + let successful_import = statuses + .iter() + .filter(|status| matches!(status.status, ImportKeystoreStatus::Imported)) + .count(); + + if successful_import > 0 { + info!( + log, + "Imported keystores via standard HTTP API"; + "count" => successful_import, + ); + } + Ok(ImportKeystoresResponse { data: statuses }) } @@ -238,7 +245,23 @@ pub fn delete( task_executor: TaskExecutor, log: Logger, ) -> Result { - let export_response = export(request, validator_store, task_executor, log)?; + let export_response = export(request, validator_store, task_executor, log.clone())?; + + // Check the status is Deleted to confirm deletion is successful, then only display the log + let successful_deletion = export_response + .data + .iter() + .filter(|response| matches!(response.status.status, DeleteKeystoreStatus::Deleted)) + .count(); + + if successful_deletion > 0 { + info!( + log, + "Deleted keystore via standard HTTP API"; + "count" => successful_deletion, + ); + } + Ok(DeleteKeystoresResponse { data: export_response .data diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index ebcde6a8288..92267ad8755 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -20,6 +20,7 @@ tree_hash = { workspace = true } eth2 = { workspace = true } hex = { workspace = true } tokio = { workspace = true } +derivative = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/validator_manager/src/create_validators.rs b/validator_manager/src/create_validators.rs index 37a6040a9b0..d4403b46131 100644 --- a/validator_manager/src/create_validators.rs +++ b/validator_manager/src/create_validators.rs @@ -45,15 +45,6 @@ pub fn cli_app() -> Command { Another, optional JSON file is created which contains a list of validator \ deposits in the same format as the \"ethereum/staking-deposit-cli\" tool.", ) - .arg( - Arg::new("help") - .long("help") - .short('h') - .help("Prints help information") - .action(ArgAction::HelpLong) - .display_order(0) - .help_heading(FLAG_HEADER), - ) .arg( Arg::new(OUTPUT_PATH_FLAG) .long(OUTPUT_PATH_FLAG) diff --git a/validator_manager/src/delete_validators.rs b/validator_manager/src/delete_validators.rs new file mode 100644 index 00000000000..6283279986a --- /dev/null +++ b/validator_manager/src/delete_validators.rs @@ -0,0 +1,293 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use eth2::{ + lighthouse_vc::types::{DeleteKeystoreStatus, DeleteKeystoresRequest}, + SensitiveUrl, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use types::PublicKeyBytes; + +use crate::{common::vc_http_client, DumpConfig}; + +pub const CMD: &str = "delete"; +pub const VC_URL_FLAG: &str = "vc-url"; +pub const VC_TOKEN_FLAG: &str = "vc-token"; +pub const VALIDATOR_FLAG: &str = "validators"; + +#[derive(Debug)] +pub enum DeleteError { + InvalidPublicKey, + DeleteFailed(eth2::Error), +} + +pub fn cli_app() -> Command { + Command::new(CMD) + .about("Deletes one or more validators from a validator client using the HTTP API.") + .arg( + Arg::new(VC_URL_FLAG) + .long(VC_URL_FLAG) + .value_name("HTTP_ADDRESS") + .help("A HTTP(S) address of a validator client using the keymanager-API.") + .default_value("http://localhost:5062") + .requires(VC_TOKEN_FLAG) + .action(ArgAction::Set) + .display_order(0), + ) + .arg( + Arg::new(VC_TOKEN_FLAG) + .long(VC_TOKEN_FLAG) + .value_name("PATH") + .help("The file containing a token required by the validator client.") + .action(ArgAction::Set) + .display_order(0), + ) + .arg( + Arg::new(VALIDATOR_FLAG) + .long(VALIDATOR_FLAG) + .value_name("STRING") + .help("Comma-separated list of validators (pubkey) that will be deleted.") + .action(ArgAction::Set) + .required(true) + .display_order(0), + ) +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct DeleteConfig { + pub vc_url: SensitiveUrl, + pub vc_token_path: PathBuf, + pub validators_to_delete: Vec, +} + +impl DeleteConfig { + fn from_cli(matches: &ArgMatches) -> Result { + let validators_to_delete_str = + clap_utils::parse_required::(matches, VALIDATOR_FLAG)?; + + let validators_to_delete = validators_to_delete_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()?; + + Ok(Self { + vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, + validators_to_delete, + vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + }) + } +} + +pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<(), String> { + let config = DeleteConfig::from_cli(matches)?; + if dump_config.should_exit_early(&config)? { + Ok(()) + } else { + run(config).await + } +} + +async fn run<'a>(config: DeleteConfig) -> Result<(), String> { + let DeleteConfig { + vc_url, + vc_token_path, + validators_to_delete, + } = config; + + let (http_client, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + + for validator_to_delete in &validators_to_delete { + if !validators + .iter() + .any(|validator| &validator.validating_pubkey == validator_to_delete) + { + return Err(format!("Validator {} doesn't exist", validator_to_delete)); + } + } + + let delete_request = DeleteKeystoresRequest { + pubkeys: validators_to_delete.clone(), + }; + + let responses = http_client + .delete_keystores(&delete_request) + .await + .map_err(|e| format!("Error deleting keystore {}", e))? + .data; + + let mut error = false; + for (validator_to_delete, response) in validators_to_delete.iter().zip(responses.iter()) { + if response.status == DeleteKeystoreStatus::Error + || response.status == DeleteKeystoreStatus::NotFound + || response.status == DeleteKeystoreStatus::NotActive + { + error = true; + eprintln!( + "Problem with removing validator {:?}, status: {:?}", + validator_to_delete, response.status + ); + } + } + if error { + return Err("Problem with removing one or more validators".to_string()); + } + + eprintln!("Validator(s) deleted"); + Ok(()) +} + +#[cfg(not(debug_assertions))] +#[cfg(test)] +mod test { + use std::{ + fs::{self, File}, + io::Write, + str::FromStr, + }; + + use super::*; + use crate::{ + common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, + }; + use validator_client::http_api::{test_utils::ApiTester, Config as HttpConfig}; + + struct TestBuilder { + delete_config: Option, + src_import_builder: Option, + http_config: HttpConfig, + vc_token: Option, + validators: Vec, + } + + impl TestBuilder { + async fn new() -> Self { + Self { + delete_config: None, + src_import_builder: None, + http_config: ApiTester::default_http_config(), + vc_token: None, + validators: vec![], + } + } + + async fn with_validators( + mut self, + count: u32, + first_index: u32, + indices_of_validators_to_delete: Vec, + ) -> Self { + let builder = ImportTestBuilder::new_with_http_config(self.http_config.clone()) + .await + .create_validators(count, first_index) + .await; + + self.vc_token = + Some(fs::read_to_string(builder.get_import_config().vc_token_path).unwrap()); + + let local_validators: Vec = { + let contents = + fs::read_to_string(builder.get_import_config().validators_file_path.unwrap()) + .unwrap(); + serde_json::from_str(&contents).unwrap() + }; + + let import_config = builder.get_import_config(); + + let validators_to_delete = indices_of_validators_to_delete + .iter() + .map(|&index| { + PublicKeyBytes::from_str( + format!("0x{}", local_validators[index].voting_keystore.pubkey()).as_str(), + ) + .unwrap() + }) + .collect(); + + self.delete_config = Some(DeleteConfig { + vc_url: import_config.vc_url, + vc_token_path: import_config.vc_token_path, + validators_to_delete, + }); + + self.validators = local_validators.clone(); + self.src_import_builder = Some(builder); + self + } + + pub async fn run_test(self) -> TestResult { + let import_builder = self.src_import_builder.unwrap(); + let import_test_result = import_builder.run_test().await; + assert!(import_test_result.result.is_ok()); + + let path = self.delete_config.clone().unwrap().vc_token_path; + let url = self.delete_config.clone().unwrap().vc_url; + let parent = path.parent().unwrap(); + + fs::create_dir_all(parent).expect("Was not able to create parent directory"); + + File::options() + .write(true) + .read(true) + .create(true) + .truncate(true) + .open(path.clone()) + .unwrap() + .write_all(self.vc_token.clone().unwrap().as_bytes()) + .unwrap(); + + let result = run(self.delete_config.clone().unwrap()).await; + + if result.is_ok() { + let (_, list_keystores_response) = vc_http_client(url, path.clone()).await.unwrap(); + + // The remaining number of active keystores (left) = Total validators - Deleted validators (right) + assert_eq!( + list_keystores_response.len(), + self.validators.len() + - self + .delete_config + .clone() + .unwrap() + .validators_to_delete + .len() + ); + + // Check the remaining validator keys are not in validators_to_delete + assert!(list_keystores_response.iter().all(|keystore| { + !self + .delete_config + .clone() + .unwrap() + .validators_to_delete + .contains(&keystore.validating_pubkey) + })); + + return TestResult { result: Ok(()) }; + } + + TestResult { + result: Err(result.unwrap_err()), + } + } + } + + #[must_use] + struct TestResult { + result: Result<(), String>, + } + + impl TestResult { + fn assert_ok(self) { + assert_eq!(self.result, Ok(())) + } + } + #[tokio::test] + async fn delete_multiple_validators() { + TestBuilder::new() + .await + .with_validators(3, 0, vec![0, 1, 2]) + .await + .run_test() + .await + .assert_ok(); + } +} diff --git a/validator_manager/src/import_validators.rs b/validator_manager/src/import_validators.rs index f193e8d0fbd..6065ecb6035 100644 --- a/validator_manager/src/import_validators.rs +++ b/validator_manager/src/import_validators.rs @@ -1,16 +1,28 @@ use super::common::*; use crate::DumpConfig; +use account_utils::{eth2_keystore::Keystore, ZeroizeString}; use clap::{Arg, ArgAction, ArgMatches, Command}; use clap_utils::FLAG_HEADER; +use derivative::Derivative; +use eth2::lighthouse_vc::types::KeystoreJsonStr; use eth2::{lighthouse_vc::std_types::ImportKeystoreStatus, SensitiveUrl}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use types::Address; pub const CMD: &str = "import"; pub const VALIDATORS_FILE_FLAG: &str = "validators-file"; +pub const KEYSTORE_FILE_FLAG: &str = "keystore-file"; pub const VC_URL_FLAG: &str = "vc-url"; pub const VC_TOKEN_FLAG: &str = "vc-token"; +pub const PASSWORD: &str = "password"; +pub const FEE_RECIPIENT: &str = "suggested-fee-recipient"; +pub const GAS_LIMIT: &str = "gas-limit"; +pub const BUILDER_PROPOSALS: &str = "builder-proposals"; +pub const BUILDER_BOOST_FACTOR: &str = "builder-boost-factor"; +pub const PREFER_BUILDER_PROPOSALS: &str = "prefer-builder-proposals"; +pub const ENABLED: &str = "enabled"; pub const DETECTED_DUPLICATE_MESSAGE: &str = "Duplicate validator detected!"; @@ -21,15 +33,6 @@ pub fn cli_app() -> Command { are defined in a JSON file which can be generated using the \"create-validators\" \ command.", ) - .arg( - Arg::new("help") - .long("help") - .short('h') - .help("Prints help information") - .action(ArgAction::HelpLong) - .display_order(0) - .help_heading(FLAG_HEADER), - ) .arg( Arg::new(VALIDATORS_FILE_FLAG) .long(VALIDATORS_FILE_FLAG) @@ -39,19 +42,32 @@ pub fn cli_app() -> Command { imported to the validator client. This file is usually named \ \"validators.json\".", ) - .required(true) .action(ArgAction::Set) - .display_order(0), + .display_order(0) + .required_unless_present("keystore-file") + .conflicts_with("keystore-file"), + ) + .arg( + Arg::new(KEYSTORE_FILE_FLAG) + .long(KEYSTORE_FILE_FLAG) + .value_name("PATH_TO_KEYSTORE_FILE") + .help( + "The path to a keystore JSON file to be \ + imported to the validator client. This file is usually created \ + using staking-deposit-cli or ethstaker-deposit-cli", + ) + .action(ArgAction::Set) + .display_order(0) + .conflicts_with("validators-file") + .required_unless_present("validators-file") + .requires(PASSWORD), ) .arg( Arg::new(VC_URL_FLAG) .long(VC_URL_FLAG) .value_name("HTTP_ADDRESS") .help( - "A HTTP(S) address of a validator client using the keymanager-API. \ - If this value is not supplied then a 'dry run' will be conducted where \ - no changes are made to the validator client.", - ) + "A HTTP(S) address of a validator client using the keymanager-API.") .default_value("http://localhost:5062") .requires(VC_TOKEN_FLAG) .action(ArgAction::Set) @@ -80,29 +96,111 @@ pub fn cli_app() -> Command { ) .display_order(0), ) + .arg( + Arg::new(PASSWORD) + .long(PASSWORD) + .value_name("STRING") + .help("Password of the keystore file.") + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) + .arg( + Arg::new(FEE_RECIPIENT) + .long(FEE_RECIPIENT) + .value_name("ETH1_ADDRESS") + .help("When provided, the imported validator will use the suggested fee recipient. Omit this flag to use the default value from the VC.") + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) + .arg( + Arg::new(GAS_LIMIT) + .long(GAS_LIMIT) + .value_name("UINT64") + .help("When provided, the imported validator will use this gas limit. It is recommended \ + to leave this as the default value by not specifying this flag.",) + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) + .arg( + Arg::new(BUILDER_PROPOSALS) + .long(BUILDER_PROPOSALS) + .help("When provided, the imported validator will attempt to create \ + blocks via builder rather than the local EL.",) + .value_parser(["true","false"]) + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) + .arg( + Arg::new(BUILDER_BOOST_FACTOR) + .long(BUILDER_BOOST_FACTOR) + .value_name("UINT64") + .help("When provided, the imported validator will use this \ + percentage multiplier to apply to the builder's payload value \ + when choosing between a builder payload header and payload from \ + the local execution node.",) + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) + .arg( + Arg::new(PREFER_BUILDER_PROPOSALS) + .long(PREFER_BUILDER_PROPOSALS) + .help("When provided, the imported validator will always prefer blocks \ + constructed by builders, regardless of payload value.",) + .value_parser(["true","false"]) + .action(ArgAction::Set) + .display_order(0) + .requires(KEYSTORE_FILE_FLAG), + ) } -#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize, Derivative)] +#[derivative(Debug)] pub struct ImportConfig { - pub validators_file_path: PathBuf, + pub validators_file_path: Option, + pub keystore_file_path: Option, pub vc_url: SensitiveUrl, pub vc_token_path: PathBuf, pub ignore_duplicates: bool, + #[derivative(Debug = "ignore")] + pub password: Option, + pub fee_recipient: Option
, + pub gas_limit: Option, + pub builder_proposals: Option, + pub builder_boost_factor: Option, + pub prefer_builder_proposals: Option, + pub enabled: Option, } impl ImportConfig { fn from_cli(matches: &ArgMatches) -> Result { Ok(Self { - validators_file_path: clap_utils::parse_required(matches, VALIDATORS_FILE_FLAG)?, + validators_file_path: clap_utils::parse_optional(matches, VALIDATORS_FILE_FLAG)?, + keystore_file_path: clap_utils::parse_optional(matches, KEYSTORE_FILE_FLAG)?, vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, ignore_duplicates: matches.get_flag(IGNORE_DUPLICATES_FLAG), + password: clap_utils::parse_optional(matches, PASSWORD)?, + fee_recipient: clap_utils::parse_optional(matches, FEE_RECIPIENT)?, + gas_limit: clap_utils::parse_optional(matches, GAS_LIMIT)?, + builder_proposals: clap_utils::parse_optional(matches, BUILDER_PROPOSALS)?, + builder_boost_factor: clap_utils::parse_optional(matches, BUILDER_BOOST_FACTOR)?, + prefer_builder_proposals: clap_utils::parse_optional( + matches, + PREFER_BUILDER_PROPOSALS, + )?, + enabled: clap_utils::parse_optional(matches, ENABLED)?, }) } } pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<(), String> { let config = ImportConfig::from_cli(matches)?; + if dump_config.should_exit_early(&config)? { Ok(()) } else { @@ -113,27 +211,61 @@ pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<() async fn run<'a>(config: ImportConfig) -> Result<(), String> { let ImportConfig { validators_file_path, + keystore_file_path, vc_url, vc_token_path, ignore_duplicates, + password, + fee_recipient, + gas_limit, + builder_proposals, + builder_boost_factor, + prefer_builder_proposals, + enabled, } = config; - if !validators_file_path.exists() { - return Err(format!("Unable to find file at {:?}", validators_file_path)); - } + let validators: Vec = + if let Some(validators_format_path) = &validators_file_path { + if !validators_format_path.exists() { + return Err(format!( + "Unable to find file at {:?}", + validators_format_path + )); + } - let validators_file = fs::OpenOptions::new() - .read(true) - .create(false) - .open(&validators_file_path) - .map_err(|e| format!("Unable to open {:?}: {:?}", validators_file_path, e))?; - let validators: Vec = serde_json::from_reader(&validators_file) - .map_err(|e| { - format!( - "Unable to parse JSON in {:?}: {:?}", - validators_file_path, e - ) - })?; + let validators_file = fs::OpenOptions::new() + .read(true) + .create(false) + .open(validators_format_path) + .map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?; + + serde_json::from_reader(&validators_file).map_err(|e| { + format!( + "Unable to parse JSON in {:?}: {:?}", + validators_format_path, e + ) + })? + } else if let Some(keystore_format_path) = &keystore_file_path { + vec![ValidatorSpecification { + voting_keystore: KeystoreJsonStr( + Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?, + ), + voting_keystore_password: password.ok_or_else(|| { + "The --password flag is required to supply the keystore password".to_string() + })?, + slashing_protection: None, + fee_recipient, + gas_limit, + builder_proposals, + builder_boost_factor, + prefer_builder_proposals, + enabled, + }] + } else { + return Err(format!( + "One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required." + )); + }; let count = validators.len(); @@ -250,7 +382,10 @@ async fn run<'a>(config: ImportConfig) -> Result<(), String> { pub mod tests { use super::*; use crate::create_validators::tests::TestBuilder as CreateTestBuilder; - use std::fs; + use std::{ + fs::{self, File}, + str::FromStr, + }; use tempfile::{tempdir, TempDir}; use validator_client::http_api::{test_utils::ApiTester, Config as HttpConfig}; @@ -279,10 +414,18 @@ pub mod tests { Self { import_config: ImportConfig { // This field will be overwritten later on. - validators_file_path: dir.path().into(), + validators_file_path: Some(dir.path().into()), + keystore_file_path: Some(dir.path().into()), vc_url: vc.url.clone(), vc_token_path, ignore_duplicates: false, + password: Some(ZeroizeString::from_str("password").unwrap()), + fee_recipient: None, + builder_boost_factor: None, + gas_limit: None, + builder_proposals: None, + enabled: None, + prefer_builder_proposals: None, }, vc, create_dir: None, @@ -295,6 +438,10 @@ pub mod tests { self } + pub fn get_import_config(&self) -> ImportConfig { + self.import_config.clone() + } + pub async fn create_validators(mut self, count: u32, first_index: u32) -> Self { let create_result = CreateTestBuilder::default() .mutate_config(|config| { @@ -307,7 +454,55 @@ pub mod tests { create_result.result.is_ok(), "precondition: validators are created" ); - self.import_config.validators_file_path = create_result.validators_file_path(); + self.import_config.validators_file_path = Some(create_result.validators_file_path()); + self.create_dir = Some(create_result.output_dir); + self + } + + // Keystore JSON requires a different format when creating valdiators + pub async fn create_validators_keystore_format( + mut self, + count: u32, + first_index: u32, + ) -> Self { + let create_result = CreateTestBuilder::default() + .mutate_config(|config| { + config.count = count; + config.first_index = first_index; + }) + .run_test() + .await; + assert!( + create_result.result.is_ok(), + "precondition: validators are created" + ); + + let validators_file_path = create_result.validators_file_path(); + + let validators_file = fs::OpenOptions::new() + .read(true) + .create(false) + .open(&validators_file_path) + .map_err(|e| format!("Unable to open {:?}: {:?}", validators_file_path, e)) + .unwrap(); + + let validators: Vec = serde_json::from_reader(&validators_file) + .map_err(|e| { + format!( + "Unable to parse JSON in {:?}: {:?}", + validators_file_path, e + ) + }) + .unwrap(); + + let validator = &validators[0]; + let validator_json = validator.voting_keystore.0.clone(); + + let keystore_file = File::create(&validators_file_path).unwrap(); + let _ = validator_json.to_json_writer(keystore_file); + + self.import_config.keystore_file_path = Some(create_result.validators_file_path()); + self.import_config.password = Some(validator.voting_keystore_password.clone()); self.create_dir = Some(create_result.output_dir); self } @@ -327,7 +522,8 @@ pub mod tests { let local_validators: Vec = { let contents = - fs::read_to_string(&self.import_config.validators_file_path).unwrap(); + fs::read_to_string(&self.import_config.validators_file_path.unwrap()) + .unwrap(); serde_json::from_str(&contents).unwrap() }; let list_keystores_response = self.vc.client.get_keystores().await.unwrap().data; @@ -355,6 +551,39 @@ pub mod tests { vc: self.vc, } } + + pub async fn run_test_keystore_format(self) -> TestResult { + let result = run(self.import_config.clone()).await; + + if result.is_ok() { + self.vc.ensure_key_cache_consistency().await; + + let local_keystore: Keystore = + Keystore::from_json_file(&self.import_config.keystore_file_path.unwrap()) + .unwrap(); + + let list_keystores_response = self.vc.client.get_keystores().await.unwrap().data; + + assert_eq!( + 1, + list_keystores_response.len(), + "vc should have exactly the number of validators imported" + ); + + let local_pubkey = local_keystore.public_key().unwrap().into(); + let remote_validator = list_keystores_response + .iter() + .find(|validator| validator.validating_pubkey == local_pubkey) + .expect("validator must exist on VC"); + assert_eq!(&remote_validator.derivation_path, &local_keystore.path()); + assert_eq!(remote_validator.readonly, Some(false)); + } + + TestResult { + result, + vc: self.vc, + } + } } #[must_use] // Use the `assert_ok` or `assert_err` fns to "use" this value. @@ -445,4 +674,66 @@ pub mod tests { .await .assert_ok(); } + + #[tokio::test] + async fn create_one_validator_keystore_format() { + TestBuilder::new() + .await + .mutate_import_config(|config| { + // Set validators_file_path to None so that keystore_file_path is used for tests with the keystore format + config.validators_file_path = None; + }) + .create_validators_keystore_format(1, 0) + .await + .run_test_keystore_format() + .await + .assert_ok(); + } + + #[tokio::test] + async fn create_one_validator_with_offset_keystore_format() { + TestBuilder::new() + .await + .mutate_import_config(|config| { + config.validators_file_path = None; + }) + .create_validators_keystore_format(1, 42) + .await + .run_test_keystore_format() + .await + .assert_ok(); + } + + #[tokio::test] + async fn import_duplicates_when_disallowed_keystore_format() { + TestBuilder::new() + .await + .mutate_import_config(|config| { + config.validators_file_path = None; + }) + .create_validators_keystore_format(1, 0) + .await + .import_validators_without_checks() + .await + .run_test_keystore_format() + .await + .assert_err_contains("DuplicateValidator"); + } + + #[tokio::test] + async fn import_duplicates_when_allowed_keystore_format() { + TestBuilder::new() + .await + .mutate_import_config(|config| { + config.ignore_duplicates = true; + config.validators_file_path = None; + }) + .create_validators_keystore_format(1, 0) + .await + .import_validators_without_checks() + .await + .run_test_keystore_format() + .await + .assert_ok(); + } } diff --git a/validator_manager/src/lib.rs b/validator_manager/src/lib.rs index 222dd7076de..8e43cd59772 100644 --- a/validator_manager/src/lib.rs +++ b/validator_manager/src/lib.rs @@ -8,7 +8,9 @@ use types::EthSpec; pub mod common; pub mod create_validators; +pub mod delete_validators; pub mod import_validators; +pub mod list_validators; pub mod move_validators; pub const CMD: &str = "validator_manager"; @@ -51,11 +53,14 @@ pub fn cli_app() -> Command { .help("Prints help information") .action(ArgAction::HelpLong) .display_order(0) - .help_heading(FLAG_HEADER), + .help_heading(FLAG_HEADER) + .global(true), ) .subcommand(create_validators::cli_app()) .subcommand(import_validators::cli_app()) .subcommand(move_validators::cli_app()) + .subcommand(list_validators::cli_app()) + .subcommand(delete_validators::cli_app()) } /// Run the account manager, returning an error if the operation did not succeed. @@ -83,6 +88,13 @@ pub fn run(matches: &ArgMatches, env: Environment) -> Result<(), Some((move_validators::CMD, matches)) => { move_validators::cli_run(matches, dump_config).await } + Some((list_validators::CMD, matches)) => { + list_validators::cli_run(matches, dump_config).await + } + Some((delete_validators::CMD, matches)) => { + delete_validators::cli_run(matches, dump_config).await + } + Some(("", _)) => Err("No command supplied. See --help.".to_string()), Some((unknown, _)) => Err(format!( "{} is not a valid {} command. See --help.", unknown, CMD diff --git a/validator_manager/src/list_validators.rs b/validator_manager/src/list_validators.rs new file mode 100644 index 00000000000..7df85a7eb9d --- /dev/null +++ b/validator_manager/src/list_validators.rs @@ -0,0 +1,201 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use eth2::lighthouse_vc::types::SingleKeystoreResponse; +use eth2::SensitiveUrl; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::{common::vc_http_client, DumpConfig}; + +pub const CMD: &str = "list"; +pub const VC_URL_FLAG: &str = "vc-url"; +pub const VC_TOKEN_FLAG: &str = "vc-token"; + +pub fn cli_app() -> Command { + Command::new(CMD) + .about("Lists all validators in a validator client using the HTTP API.") + .arg( + Arg::new(VC_URL_FLAG) + .long(VC_URL_FLAG) + .value_name("HTTP_ADDRESS") + .help("A HTTP(S) address of a validator client using the keymanager-API.") + .default_value("http://localhost:5062") + .requires(VC_TOKEN_FLAG) + .action(ArgAction::Set) + .display_order(0), + ) + .arg( + Arg::new(VC_TOKEN_FLAG) + .long(VC_TOKEN_FLAG) + .value_name("PATH") + .help("The file containing a token required by the validator client.") + .action(ArgAction::Set) + .display_order(0), + ) +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct ListConfig { + pub vc_url: SensitiveUrl, + pub vc_token_path: PathBuf, +} + +impl ListConfig { + fn from_cli(matches: &ArgMatches) -> Result { + Ok(Self { + vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, + vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + }) + } +} + +pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<(), String> { + let config = ListConfig::from_cli(matches)?; + if dump_config.should_exit_early(&config)? { + Ok(()) + } else { + run(config).await?; + Ok(()) + } +} + +async fn run<'a>(config: ListConfig) -> Result, String> { + let ListConfig { + vc_url, + vc_token_path, + } = config; + + let (_, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + + println!("List of validators ({}):", validators.len()); + + for validator in &validators { + println!("{}", validator.validating_pubkey); + } + + Ok(validators) +} + +#[cfg(not(debug_assertions))] +#[cfg(test)] +mod test { + use std::{ + fs::{self, File}, + io::Write, + }; + + use super::*; + use crate::{ + common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, + }; + use validator_client::http_api::{test_utils::ApiTester, Config as HttpConfig}; + + struct TestBuilder { + list_config: Option, + src_import_builder: Option, + http_config: HttpConfig, + vc_token: Option, + validators: Vec, + } + + impl TestBuilder { + async fn new() -> Self { + Self { + list_config: None, + src_import_builder: None, + http_config: ApiTester::default_http_config(), + vc_token: None, + validators: vec![], + } + } + + async fn with_validators(mut self, count: u32, first_index: u32) -> Self { + let builder = ImportTestBuilder::new_with_http_config(self.http_config.clone()) + .await + .create_validators(count, first_index) + .await; + self.list_config = Some(ListConfig { + vc_url: builder.get_import_config().vc_url, + vc_token_path: builder.get_import_config().vc_token_path, + }); + + self.vc_token = + Some(fs::read_to_string(builder.get_import_config().vc_token_path).unwrap()); + + let local_validators: Vec = { + let contents = + fs::read_to_string(builder.get_import_config().validators_file_path.unwrap()) + .unwrap(); + serde_json::from_str(&contents).unwrap() + }; + + self.validators = local_validators.clone(); + self.src_import_builder = Some(builder); + self + } + + pub async fn run_test(self) -> TestResult { + let import_test_result = self.src_import_builder.unwrap().run_test().await; + assert!(import_test_result.result.is_ok()); + + let path = self.list_config.clone().unwrap().vc_token_path; + let parent = path.parent().unwrap(); + + fs::create_dir_all(parent).expect("Was not able to create parent directory"); + + File::options() + .write(true) + .read(true) + .create(true) + .truncate(true) + .open(path) + .unwrap() + .write_all(self.vc_token.clone().unwrap().as_bytes()) + .unwrap(); + + let result = run(self.list_config.clone().unwrap()).await; + + if result.is_ok() { + let result_ref = result.as_ref().unwrap(); + + for local_validator in &self.validators { + let local_keystore = &local_validator.voting_keystore.0; + let local_pubkey = local_keystore.public_key().unwrap(); + assert!( + result_ref + .iter() + .any(|validator| validator.validating_pubkey + == local_pubkey.clone().into()), + "local validator pubkey not found in result" + ); + } + + return TestResult { result: Ok(()) }; + } + + TestResult { + result: Err(result.unwrap_err()), + } + } + } + + #[must_use] // Use the `assert_ok` or `assert_err` fns to "use" this value. + struct TestResult { + result: Result<(), String>, + } + + impl TestResult { + fn assert_ok(self) { + assert_eq!(self.result, Ok(())) + } + } + #[tokio::test] + async fn list_all_validators() { + TestBuilder::new() + .await + .with_validators(3, 0) + .await + .run_test() + .await + .assert_ok(); + } +} diff --git a/validator_manager/src/move_validators.rs b/validator_manager/src/move_validators.rs index 91bc2b0ef85..7651917ea94 100644 --- a/validator_manager/src/move_validators.rs +++ b/validator_manager/src/move_validators.rs @@ -2,7 +2,6 @@ use super::common::*; use crate::DumpConfig; use account_utils::{read_password_from_user, ZeroizeString}; use clap::{Arg, ArgAction, ArgMatches, Command}; -use clap_utils::FLAG_HEADER; use eth2::{ lighthouse_vc::{ std_types::{ @@ -75,15 +74,6 @@ pub fn cli_app() -> Command { command. This command only supports validators signing via a keystore on the local \ file system (i.e., not Web3Signer validators).", ) - .arg( - Arg::new("help") - .long("help") - .short('h') - .help("Prints help information") - .action(ArgAction::HelpLong) - .display_order(0) - .help_heading(FLAG_HEADER), - ) .arg( Arg::new(SRC_VC_URL_FLAG) .long(SRC_VC_URL_FLAG)