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 <mjladson@pm.me>

* 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 <micsproul@gmail.com>

* 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
This commit is contained in:
chonghe
2024-10-29 12:14:06 +08:00
committed by GitHub
parent fe889c619c
commit fdf456f398
16 changed files with 1031 additions and 73 deletions

1
Cargo.lock generated
View File

@@ -9222,6 +9222,7 @@ dependencies = [
"account_utils",
"clap",
"clap_utils",
"derivative",
"environment",
"eth2",
"eth2_network_config",

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 <PATH_TO_JSON_FILE>
Usage: lighthouse validator_manager import [OPTIONS]
Options:
--builder-boost-factor <UINT64>
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 <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 <DIR>
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 <UINT64>
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 <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 <SECONDS>
The timeout in seconds for the request to --genesis-state-url.
[default: 180]
--keystore-file <PATH_TO_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 <FORMAT>
Specifies the log format used when emitting logs to the terminal.
[possible values: JSON]
@@ -50,6 +66,15 @@ Options:
--network <network>
Name of the Eth2 chain Lighthouse will sync and follow. [possible
values: mainnet, gnosis, chiado, sepolia, holesky]
--password <STRING>
Password of the keystore file.
--prefer-builder-proposals <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 <ETH1_ADDRESS>
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 <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 <PATH>
The file containing a token required by the validator client.
--vc-url <HTTP_ADDRESS>
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

View File

@@ -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 <API-TOKEN-PATH> --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 <API-TOKEN-PATH> --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
```

View File

@@ -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,

View File

@@ -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<Mnemonic, String> {
#[serde(transparent)]
pub struct ZeroizeString(String);
impl FromStr for ZeroizeString {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.to_owned()))
}
}
impl From<String> for ZeroizeString {
fn from(s: String) -> Self {
Self(s)

View File

@@ -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<MoveConfig> {
}
}
impl CommandLineTest<ListConfig> {
fn validators_list() -> Self {
Self::default().flag("list", None)
}
}
impl CommandLineTest<DeleteConfig> {
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);
});
}

View File

@@ -75,12 +75,6 @@ pub fn import<T: SlotClock + 'static, E: EthSpec>(
)));
}
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<T: SlotClock + 'static, E: EthSpec>(
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<T: SlotClock + 'static, E: EthSpec>(
task_executor: TaskExecutor,
log: Logger,
) -> Result<DeleteKeystoresResponse, Rejection> {
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

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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<PublicKeyBytes>,
}
impl DeleteConfig {
fn from_cli(matches: &ArgMatches) -> Result<Self, String> {
let validators_to_delete_str =
clap_utils::parse_required::<String>(matches, VALIDATOR_FLAG)?;
let validators_to_delete = validators_to_delete_str
.split(',')
.map(|s| s.trim().parse())
.collect::<Result<Vec<PublicKeyBytes>, _>>()?;
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<DeleteConfig>,
src_import_builder: Option<ImportTestBuilder>,
http_config: HttpConfig,
vc_token: Option<String>,
validators: Vec<ValidatorSpecification>,
}
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<usize>,
) -> 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<ValidatorSpecification> = {
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();
}
}

View File

@@ -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<PathBuf>,
pub keystore_file_path: Option<PathBuf>,
pub vc_url: SensitiveUrl,
pub vc_token_path: PathBuf,
pub ignore_duplicates: bool,
#[derivative(Debug = "ignore")]
pub password: Option<ZeroizeString>,
pub fee_recipient: Option<Address>,
pub gas_limit: Option<u64>,
pub builder_proposals: Option<bool>,
pub builder_boost_factor: Option<u64>,
pub prefer_builder_proposals: Option<bool>,
pub enabled: Option<bool>,
}
impl ImportConfig {
fn from_cli(matches: &ArgMatches) -> Result<Self, String> {
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<ValidatorSpecification> =
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<ValidatorSpecification> = 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<ValidatorSpecification> = 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<ValidatorSpecification> = {
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();
}
}

View File

@@ -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<E: EthSpec>(matches: &ArgMatches, env: Environment<E>) -> 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

View File

@@ -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<Self, String> {
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<Vec<SingleKeystoreResponse>, 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<ListConfig>,
src_import_builder: Option<ImportTestBuilder>,
http_config: HttpConfig,
vc_token: Option<String>,
validators: Vec<ValidatorSpecification>,
}
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<ValidatorSpecification> = {
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();
}
}

View File

@@ -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)