diff --git a/Cargo.lock b/Cargo.lock index 7d77ce4044e..5e22c9742ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9933,6 +9933,7 @@ name = "validator_manager" version = "0.1.0" dependencies = [ "account_utils", + "beacon_chain", "clap", "clap_utils", "derivative", @@ -9942,9 +9943,11 @@ dependencies = [ "eth2_wallet", "ethereum_serde_utils", "hex", + "http_api", "regex", "serde", "serde_json", + "slot_clock", "tempfile", "tokio", "tree_hash", diff --git a/book/src/help_vm.md b/book/src/help_vm.md index 8ff54122efc..f58537ae1ca 100644 --- a/book/src/help_vm.md +++ b/book/src/help_vm.md @@ -28,6 +28,10 @@ Commands: delete Deletes one or more validators from a validator client using the HTTP API. + exit + Exits one or more validators using the HTTP API. It can also be used + to generate a presigned voluntary exit message for a particular future + epoch. help Print this message or the help of the given subcommand(s) diff --git a/book/src/validator_manager.md b/book/src/validator_manager.md index b0190c1812d..609f1769013 100644 --- a/book/src/validator_manager.md +++ b/book/src/validator_manager.md @@ -32,4 +32,4 @@ The `validator-manager` boasts the following features: - [Creating and importing validators using the `create` and `import` commands.](./validator_manager_create.md) - [Moving validators between two VCs using the `move` command.](./validator_manager_move.md) -- [Managing validators such as delete, import and list validators.](./validator_manager_api.md) +- [Managing validators such as exit, delete, import and list validators.](./validator_manager_api.md) diff --git a/book/src/validator_manager_api.md b/book/src/validator_manager_api.md index 7bc5be85571..05420084631 100644 --- a/book/src/validator_manager_api.md +++ b/book/src/validator_manager_api.md @@ -2,6 +2,54 @@ 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`. By default, the validator client HTTP address is `http://localhost:5062`. If a different IP address or port is used, add the flag `--vc-url http://IP:port_number` to the command below. +## Exit + +The `exit` command exits one or more validators from the validator client. To `exit`: + +> **Important note: Once the --beacon-node flag is used, it will publish the voluntary exit to the network. This action is irreversible.** + +```bash +lighthouse vm exit --vc-token --validators pubkey1,pubkey2 --beacon-node http://beacon-node-url:5052 +``` + +Example: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4,0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f --beacon-node http://localhost:5052 +``` + +If successful, the following log will be returned: + +```text +Successfully validated and published voluntary exit for validator 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4 +Successfully validated and published voluntary exit for validator +0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f +``` + +To exit all validators on the validator client, use the keyword `all`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --beacon-node http://localhost:5052 +``` + +To check the voluntary exit status, refer to [the list command](./validator_manager_api.md#list). + +The following command will only generate a presigned voluntary exit message and save it to a file named `{validator_pubkey}.json`. It **will not** publish the voluntary exit to the network. + +To generate a presigned exit message and save it to a file, use the flag `--presign`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --presign +``` + +To generate a presigned exit message for a particular (future) epoch, use the flag `--exit-epoch`: + +```bash +lighthouse vm exit --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all --presign --exit-epoch 1234567 +``` + +The generated presigned exit message will only be valid at or after the specified exit-epoch, in this case, epoch 1234567. + ## 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`: @@ -16,6 +64,12 @@ Example: lighthouse vm delete --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8885c29b8f88ee9b9a37b480fd4384fed74bda33d85bc8171a904847e65688b6c9bb4362d6597fd30109fb2def6c3ae4,0xa262dae3dcd2b2e280af534effa16bedb27c06f2959e114d53bd2a248ca324a018dc73179899a066149471a94a1bc92f ``` +To delete all validators on the validator client, use the keyword `all`: + +```bash +lighthouse vm delete --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators all +``` + ## Import The `import` command imports validator keystores generated by the `ethstaker-deposit-cli`. To import a validator keystore: @@ -37,3 +91,26 @@ To list the validators running on the validator client: ```bash lighthouse vm list --vc-token ~/.lighthouse/mainnet/validators/api-token.txt ``` + +The `list` command can also be used to check the voluntary exit status of validators. To do so, use both `--beacon-node` and `--validators` flags. The `--validators` flag accepts a comma-separated list of validator public keys, or the keyword `all` to check the voluntary exit status of all validators attached to the validator client. + +```bash +lighthouse vm list --vc-token ~/.lighthouse/mainnet/validators/api-token.txt --validators 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 --beacon-node http://localhost:5052 +``` + +If the validator voluntary exit has been accepted by the chain, the following log will be returned: + +```text +Voluntary exit for validator 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 has been accepted into the beacon chain, but not yet finalized. Finalization may take several minutes or longer. Before finalization there is a low probability that the exit may be reverted. +Current epoch: 2, Exit epoch: 7, Withdrawable epoch: 263 +Please keep your validator running till exit epoch +Exit epoch in approximately 480 secs +``` + +When the exit epoch is reached, querying the status will return: + +```text +Validator 0x8de7ec501d574152f52a962bf588573df2fc3563fd0c6077651208ed20f24f3d8572425706b343117b48bdca56808416 has exited at epoch: 7 +``` + +You can safely shut down the validator client at this point. diff --git a/book/src/validator_voluntary_exit.md b/book/src/validator_voluntary_exit.md index 2a45852f328..ff404518b7d 100644 --- a/book/src/validator_voluntary_exit.md +++ b/book/src/validator_voluntary_exit.md @@ -10,6 +10,8 @@ A validator can initiate a voluntary exit provided that the validator is current It takes at a minimum 5 epochs (32 minutes) for a validator to exit after initiating a voluntary exit. This number can be much higher depending on how many other validators are queued to exit. +You can also perform voluntary exit for one or more validators using the validator manager, see [Managing Validators](./validator_manager_api.md#exit) for more details. + ## Initiating a voluntary exit In order to initiate an exit, users can use the `lighthouse account validator exit` command. diff --git a/lighthouse/tests/validator_manager.rs b/lighthouse/tests/validator_manager.rs index 04e3eafe6eb..5ee9b0263a9 100644 --- a/lighthouse/tests/validator_manager.rs +++ b/lighthouse/tests/validator_manager.rs @@ -10,6 +10,7 @@ use types::*; use validator_manager::{ create_validators::CreateConfig, delete_validators::DeleteConfig, + exit_validators::ExitConfig, import_validators::ImportConfig, list_validators::ListConfig, move_validators::{MoveConfig, PasswordSource, Validators}, @@ -119,6 +120,12 @@ impl CommandLineTest { } } +impl CommandLineTest { + fn validators_exit() -> Self { + Self::default().flag("exit", None) + } +} + #[test] pub fn validator_create_without_output_path() { CommandLineTest::validators_create().assert_failed(); @@ -443,6 +450,8 @@ pub fn validator_list_defaults() { let expected = ListConfig { vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), vc_token_path: PathBuf::from("./token.json"), + beacon_url: None, + validators_to_display: vec![], }; assert_eq!(expected, config); }); @@ -468,3 +477,106 @@ pub fn validator_delete_defaults() { assert_eq!(expected, config); }); } + +#[test] +pub fn validator_delete_missing_validator_flag() { + CommandLineTest::validators_delete() + .flag("--vc-token", Some("./token.json")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_defaults() { + CommandLineTest::validators_exit() + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--vc-token", Some("./token.json")) + .flag("--beacon-node", Some("http://localhost:5052")) + .assert_success(|config| { + let expected = ExitConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + validators_to_exit: vec![ + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_0).unwrap(), + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_1).unwrap(), + ], + beacon_url: Some(SensitiveUrl::parse("http://localhost:5052").unwrap()), + exit_epoch: None, + presign: false, + }; + assert_eq!(expected, config); + }); +} + +#[test] +pub fn validator_exit_exit_epoch_and_presign_flags() { + CommandLineTest::validators_exit() + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--vc-token", Some("./token.json")) + .flag("--exit-epoch", Some("1234567")) + .flag("--presign", None) + .assert_success(|config| { + let expected = ExitConfig { + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + validators_to_exit: vec![ + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_0).unwrap(), + PublicKeyBytes::from_str(EXAMPLE_PUBKEY_1).unwrap(), + ], + beacon_url: None, + exit_epoch: Some(Epoch::new(1234567)), + presign: true, + }; + assert_eq!(expected, config); + }); +} + +#[test] +pub fn validator_exit_missing_validator_flag() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_using_beacon_and_presign_flags() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--beacon-node", Some("http://localhost:1001")) + .flag("--presign", None) + .assert_failed(); +} + +#[test] +pub fn validator_exit_using_beacon_and_exit_epoch_flags() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--beacon-node", Some("http://localhost:1001")) + .flag("--exit-epoch", Some("1234567")) + .assert_failed(); +} + +#[test] +pub fn validator_exit_exit_epoch_flag_without_presign_flag() { + CommandLineTest::validators_exit() + .flag("--vc-token", Some("./token.json")) + .flag( + "--validators", + Some(&format!("{},{}", EXAMPLE_PUBKEY_0, EXAMPLE_PUBKEY_1)), + ) + .flag("--exit-epoch", Some("1234567")) + .assert_failed(); +} diff --git a/validator_client/http_api/src/test_utils.rs b/validator_client/http_api/src/test_utils.rs index 8c23f79fd3b..feb71c3a467 100644 --- a/validator_client/http_api/src/test_utils.rs +++ b/validator_client/http_api/src/test_utils.rs @@ -26,6 +26,7 @@ use std::time::Duration; use task_executor::test_utils::TestRuntime; use tempfile::{tempdir, TempDir}; use tokio::sync::oneshot; +use types::ChainSpec; use validator_services::block_service::BlockService; use zeroize::Zeroizing; @@ -61,6 +62,7 @@ pub struct ApiTester { pub _server_shutdown: oneshot::Sender<()>, pub validator_dir: TempDir, pub secrets_dir: TempDir, + pub spec: Arc, } impl ApiTester { @@ -69,6 +71,19 @@ impl ApiTester { } pub async fn new_with_http_config(http_config: HttpConfig) -> Self { + let slot_clock = + TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1)); + let genesis_validators_root = Hash256::repeat_byte(42); + let spec = Arc::new(E::default_spec()); + Self::new_with_options(http_config, slot_clock, genesis_validators_root, spec).await + } + + pub async fn new_with_options( + http_config: HttpConfig, + slot_clock: TestingSlotClock, + genesis_validators_root: Hash256, + spec: Arc, + ) -> Self { let validator_dir = tempdir().unwrap(); let secrets_dir = tempdir().unwrap(); let token_path = tempdir().unwrap().path().join(PK_FILENAME); @@ -91,20 +106,15 @@ impl ApiTester { ..Default::default() }; - let spec = Arc::new(E::default_spec()); - let slashing_db_path = validator_dir.path().join(SLASHING_PROTECTION_FILENAME); let slashing_protection = SlashingDatabase::open_or_create(&slashing_db_path).unwrap(); - let slot_clock = - TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1)); - let test_runtime = TestRuntime::default(); let validator_store = Arc::new(LighthouseValidatorStore::new( initialized_validators, slashing_protection, - Hash256::repeat_byte(42), + genesis_validators_root, spec.clone(), Some(Arc::new(DoppelgangerService::default())), slot_clock.clone(), @@ -127,7 +137,7 @@ impl ApiTester { validator_store: Some(validator_store.clone()), graffiti_file: None, graffiti_flag: Some(Graffiti::default()), - spec, + spec: spec.clone(), config: http_config, sse_logging_components: None, slot_clock, @@ -161,6 +171,7 @@ impl ApiTester { _server_shutdown: shutdown_tx, validator_dir, secrets_dir, + spec, } } diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index 7cb05616f47..9192f0e86b0 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -17,12 +17,15 @@ ethereum_serde_utils = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +slot_clock = { workspace = true } tokio = { workspace = true } tree_hash = { workspace = true } types = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +beacon_chain = { workspace = true } +http_api = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } validator_http_api = { workspace = true } diff --git a/validator_manager/src/delete_validators.rs b/validator_manager/src/delete_validators.rs index 5ef647c5af1..cb0557427ca 100644 --- a/validator_manager/src/delete_validators.rs +++ b/validator_manager/src/delete_validators.rs @@ -45,7 +45,10 @@ pub fn cli_app() -> Command { Arg::new(VALIDATOR_FLAG) .long(VALIDATOR_FLAG) .value_name("STRING") - .help("Comma-separated list of validators (pubkey) that will be deleted.") + .help( + "Comma-separated list of validators (pubkey) that will be deleted. \ + To delete all validators, use the keyword \"all\".", + ) .action(ArgAction::Set) .required(true) .display_order(0), @@ -64,10 +67,14 @@ impl DeleteConfig { 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::, _>>()?; + let validators_to_delete = if validators_to_delete_str.trim() == "all" { + Vec::new() + } else { + validators_to_delete_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + }; Ok(Self { vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, @@ -90,11 +97,16 @@ async fn run(config: DeleteConfig) -> Result<(), String> { let DeleteConfig { vc_url, vc_token_path, - validators_to_delete, + mut validators_to_delete, } = config; let (http_client, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + // Delete all validators on the VC + if validators_to_delete.is_empty() { + validators_to_delete = validators.iter().map(|v| v.validating_pubkey).collect(); + } + for validator_to_delete in &validators_to_delete { if !validators .iter() diff --git a/validator_manager/src/exit_validators.rs b/validator_manager/src/exit_validators.rs new file mode 100644 index 00000000000..30d8c5c47db --- /dev/null +++ b/validator_manager/src/exit_validators.rs @@ -0,0 +1,585 @@ +use crate::{common::vc_http_client, DumpConfig}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use clap_utils::FLAG_HEADER; +use eth2::types::{ConfigAndPreset, Epoch, StateId, ValidatorId, ValidatorStatus}; +use eth2::{BeaconNodeHttpClient, SensitiveUrl, Timeouts}; +use serde::{Deserialize, Serialize}; +use serde_json; +use slot_clock::{SlotClock, SystemTimeSlotClock}; +use std::fs::write; +use std::path::PathBuf; +use std::time::Duration; +use types::{ChainSpec, EthSpec, PublicKeyBytes}; + +pub const CMD: &str = "exit"; +pub const BEACON_URL_FLAG: &str = "beacon-node"; +pub const VC_URL_FLAG: &str = "vc-url"; +pub const VC_TOKEN_FLAG: &str = "vc-token"; +pub const VALIDATOR_FLAG: &str = "validators"; +pub const EXIT_EPOCH_FLAG: &str = "exit-epoch"; +pub const PRESIGN_FLAG: &str = "presign"; + +pub fn cli_app() -> Command { + Command::new(CMD) + .about( + "Exits one or more validators using the HTTP API. It can \ + also be used to generate a presigned voluntary exit message for a particular future epoch.", + ) + .arg( + Arg::new(BEACON_URL_FLAG) + .long(BEACON_URL_FLAG) + .value_name("NETWORK_ADDRESS") + .help("Address to a beacon node HTTP API") + .action(ArgAction::Set) + .display_order(0) + .conflicts_with(PRESIGN_FLAG), + ) + .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) to exit. \ + To exit all validators, use the keyword \"all\".", + ) + .action(ArgAction::Set) + .required(true) + .display_order(0), + ) + .arg( + Arg::new(EXIT_EPOCH_FLAG) + .long(EXIT_EPOCH_FLAG) + .value_name("EPOCH") + .help( + "Provide the minimum epoch for processing voluntary exit. \ + This flag is required to be used in combination with `--presign` to \ + save the voluntary exit presign to a file for future use.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(PRESIGN_FLAG) + .conflicts_with(BEACON_URL_FLAG), + ) + .arg( + Arg::new(PRESIGN_FLAG) + .long(PRESIGN_FLAG) + .help( + "Generate the voluntary exit presign and save it to a file \ + named {validator_pubkey}.json. Note: Using this without the \ + `--beacon-node` flag will not publish the voluntary exit to the network.", + ) + .help_heading(FLAG_HEADER) + .action(ArgAction::SetTrue) + .display_order(0) + .conflicts_with(BEACON_URL_FLAG), + ) +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct ExitConfig { + pub vc_url: SensitiveUrl, + pub vc_token_path: PathBuf, + pub validators_to_exit: Vec, + pub beacon_url: Option, + pub exit_epoch: Option, + pub presign: bool, +} + +impl ExitConfig { + fn from_cli(matches: &ArgMatches) -> Result { + let validators_to_exit_str = clap_utils::parse_required::(matches, VALIDATOR_FLAG)?; + + // Keyword "all" to exit all validators, vector to be created later + let validators_to_exit = if validators_to_exit_str.trim() == "all" { + Vec::new() + } else { + validators_to_exit_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + }; + + Ok(Self { + vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, + validators_to_exit, + beacon_url: clap_utils::parse_optional(matches, BEACON_URL_FLAG)?, + exit_epoch: clap_utils::parse_optional(matches, EXIT_EPOCH_FLAG)?, + presign: matches.get_flag(PRESIGN_FLAG), + }) + } +} + +pub async fn cli_run( + matches: &ArgMatches, + dump_config: DumpConfig, +) -> Result<(), String> { + let config = ExitConfig::from_cli(matches)?; + + if dump_config.should_exit_early(&config)? { + Ok(()) + } else { + run::(config).await + } +} + +async fn run(config: ExitConfig) -> Result<(), String> { + let ExitConfig { + vc_url, + vc_token_path, + mut validators_to_exit, + beacon_url, + exit_epoch, + presign, + } = config; + + let (http_client, validators) = vc_http_client(vc_url.clone(), &vc_token_path).await?; + + if validators_to_exit.is_empty() { + validators_to_exit = validators.iter().map(|v| v.validating_pubkey).collect(); + } + + for validator_to_exit in validators_to_exit { + // Check that the validators_to_exit is in the validator client + if !validators + .iter() + .any(|validator| validator.validating_pubkey == validator_to_exit) + { + return Err(format!("Validator {} doesn't exist", validator_to_exit)); + } + + let exit_message = http_client + .post_validator_voluntary_exit(&validator_to_exit, exit_epoch) + .await + .map_err(|e| format!("Failed to generate voluntary exit message: {}", e))?; + + if presign { + let exit_message_json = serde_json::to_string(&exit_message.data); + + match exit_message_json { + Ok(json) => { + // Save the exit message to JSON file(s) + let file_path = format!("{}.json", validator_to_exit); + write(&file_path, json).map_err(|e| { + format!("Failed to write voluntary exit message to file: {}", e) + })?; + println!("Voluntary exit message saved to {}", file_path); + } + Err(e) => eprintln!("Failed to serialize voluntary exit message: {}", e), + } + } + + // Only publish the voluntary exit if the --beacon-node flag is present + if let Some(ref beacon_url) = beacon_url { + let beacon_node = BeaconNodeHttpClient::new( + SensitiveUrl::parse(beacon_url.as_ref()) + .map_err(|e| format!("Failed to parse beacon http server: {:?}", e))?, + Timeouts::set_all(Duration::from_secs(12)), + ); + + if beacon_node + .get_node_syncing() + .await + .map_err(|e| format!("Failed to get beacon node sync status: {:?}", e))? + .data + .is_syncing + { + return Err( + "Beacon node is syncing, submit the voluntary exit later when beacon node is synced" + .to_string(), + ); + } + + let genesis_data = beacon_node + .get_beacon_genesis() + .await + .map_err(|e| format!("Failed to get genesis data: {}", e))? + .data; + + let config_and_preset = beacon_node + .get_config_spec::() + .await + .map_err(|e| format!("Failed to get config spec: {}", e))? + .data; + + let spec = ChainSpec::from_config::(config_and_preset.config()) + .ok_or("Failed to create chain spec")?; + + let validator_data = beacon_node + .get_beacon_states_validator_id( + StateId::Head, + &ValidatorId::PublicKey(validator_to_exit), + ) + .await + .map_err(|e| format!("Failed to get validator details: {:?}", e))? + .ok_or_else(|| { + format!( + "Validator {} is not present in the beacon state. \ + Please ensure that your beacon node is synced \ + and the validator has been deposited.", + validator_to_exit + ) + })? + .data; + + let activation_epoch = validator_data.validator.activation_epoch; + let current_epoch = get_current_epoch::(genesis_data.genesis_time, &spec) + .ok_or("Failed to get current epoch. Please check your system time")?; + + // Check if validator is eligible for exit + if validator_data.status == ValidatorStatus::ActiveOngoing + && current_epoch < activation_epoch + spec.shard_committee_period + { + eprintln!( + "Validator {} is not eligible for exit. It will become eligible at epoch {}", + validator_to_exit, + activation_epoch + spec.shard_committee_period + ) + } else if validator_data.status != ValidatorStatus::ActiveOngoing { + eprintln!( + "Validator {} is not eligible for exit. Validator status is: {:?}", + validator_to_exit, validator_data.status + ) + } else { + // Only publish voluntary exit if validator status is ActiveOngoing + beacon_node + .post_beacon_pool_voluntary_exits(&exit_message.data) + .await + .map_err(|e| format!("Failed to publish voluntary exit: {}", e))?; + eprintln!( + "Successfully validated and published voluntary exit for validator {}", + validator_to_exit + ); + } + } + } + + Ok(()) +} + +pub fn get_current_epoch(genesis_time: u64, spec: &ChainSpec) -> Option { + let slot_clock = SystemTimeSlotClock::new( + spec.genesis_slot, + Duration::from_secs(genesis_time), + Duration::from_secs(spec.seconds_per_slot), + ); + slot_clock.now().map(|s| s.epoch(E::slots_per_epoch())) +} + +#[cfg(not(debug_assertions))] +#[cfg(test)] +mod test { + use super::*; + use crate::{ + common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, + }; + use account_utils::eth2_keystore::KeystoreBuilder; + use beacon_chain::test_utils::{AttestationStrategy, BlockStrategy}; + use eth2::lighthouse_vc::types::KeystoreJsonStr; + use http_api::test_utils::InteractiveTester; + use std::{ + fs::{self, File}, + io::Write, + sync::Arc, + }; + use types::{ChainSpec, MainnetEthSpec}; + use validator_http_api::{test_utils::ApiTester, Config as HttpConfig}; + use zeroize::Zeroizing; + type E = MainnetEthSpec; + + struct TestBuilder { + exit_config: Option, + src_import_builder: Option, + http_config: HttpConfig, + vc_token: Option, + validators: Vec, + beacon_node: InteractiveTester, + index_of_validators_to_exit: Vec, + spec: Arc, + } + + impl TestBuilder { + async fn new() -> Self { + let mut spec = ChainSpec::mainnet(); + spec.shard_committee_period = 1; + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(1)); + spec.capella_fork_epoch = Some(Epoch::new(2)); + spec.deneb_fork_epoch = Some(Epoch::new(3)); + + let beacon_node = InteractiveTester::new(Some(spec.clone()), 64).await; + + let harness = &beacon_node.harness; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let execution_ctx = mock_el.server.ctx.clone(); + + // Move to terminal block. + mock_el.server.all_payloads_valid(); + execution_ctx + .execution_block_generator + .write() + .move_to_terminal_block() + .unwrap(); + + Self { + exit_config: None, + src_import_builder: None, + http_config: ApiTester::default_http_config(), + vc_token: None, + validators: vec![], + beacon_node, + index_of_validators_to_exit: vec![], + spec: spec.into(), + } + } + + async fn with_validators(mut self, index_of_validators_to_exit: Vec) -> Self { + // Ensure genesis validators root matches the beacon node. + let genesis_validators_root = self + .beacon_node + .harness + .get_current_state() + .genesis_validators_root(); + // And use a single slot clock and same spec for BN and VC to keep things simple. + let slot_clock = self.beacon_node.harness.chain.slot_clock.clone(); + let vc = ApiTester::new_with_options( + self.http_config.clone(), + slot_clock, + genesis_validators_root, + self.spec.clone(), + ) + .await; + let mut builder = ImportTestBuilder::new_with_vc(vc).await; + + self.vc_token = + Some(fs::read_to_string(builder.get_import_config().vc_token_path).unwrap()); + + let local_validators: Vec = index_of_validators_to_exit + .iter() + .map(|&index| { + let keystore = KeystoreBuilder::new( + &self.beacon_node.harness.validator_keypairs[index], + "password".as_bytes(), + "".into(), + ) + .unwrap() + .build() + .unwrap(); + + ValidatorSpecification { + voting_keystore: KeystoreJsonStr(keystore), + voting_keystore_password: Zeroizing::new("password".into()), + slashing_protection: None, + fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + enabled: Some(true), + } + }) + .collect(); + + let beacon_url = SensitiveUrl::parse(self.beacon_node.client.as_ref()).unwrap(); + + let validators_to_exit = index_of_validators_to_exit + .iter() + .map(|&index| { + self.beacon_node.harness.validator_keypairs[index] + .pk + .clone() + .into() + }) + .collect(); + + let import_config = builder.get_import_config(); + + let validators_dir = import_config.vc_token_path.parent().unwrap(); + let validators_file = validators_dir.join("validators.json"); + + builder = builder.mutate_import_config(|config| { + config.validators_file_path = Some(validators_file.clone()); + }); + + fs::write( + &validators_file, + serde_json::to_string(&local_validators).unwrap(), + ) + .unwrap(); + + self.exit_config = Some(ExitConfig { + vc_url: import_config.vc_url, + vc_token_path: import_config.vc_token_path, + validators_to_exit, + beacon_url: Some(beacon_url), + exit_epoch: None, + presign: false, + }); + + self.validators = local_validators.clone(); + self.src_import_builder = Some(builder); + self.index_of_validators_to_exit = index_of_validators_to_exit; + self + } + + pub async fn run_test(self) -> TestResult { + let import_builder = self.src_import_builder.unwrap(); + let initialized_validators = import_builder.vc.initialized_validators.clone(); + let import_test_result = import_builder.run_test().await; + assert!(import_test_result.result.is_ok()); + + // only assign the validator index after validator is imported to the VC + for &index in &self.index_of_validators_to_exit { + initialized_validators.write().set_index( + &self.beacon_node.harness.validator_keypairs[index] + .pk + .compress(), + index as u64, + ); + } + + let path = self.exit_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.clone()) + .unwrap() + .write_all(self.vc_token.clone().unwrap().as_bytes()) + .unwrap(); + + // Advance beacon chain + self.beacon_node.harness.advance_slot(); + + self.beacon_node + .harness + .extend_chain( + 100, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let result = run::(self.exit_config.clone().unwrap()).await; + + self.beacon_node.harness.advance_slot(); + + self.beacon_node + .harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let validator_data = self + .index_of_validators_to_exit + .iter() + .map(|&index| { + self.beacon_node + .harness + .get_current_state() + .get_validator(index) + .unwrap() + .clone() + }) + .collect::>(); + + let validator_exit_epoch = validator_data + .iter() + .map(|validator| validator.exit_epoch) + .collect::>(); + + let validator_withdrawable_epoch = validator_data + .iter() + .map(|validator| validator.withdrawable_epoch) + .collect::>(); + + let current_epoch = self.beacon_node.harness.get_current_state().current_epoch(); + let max_seed_lookahead = self.beacon_node.harness.spec.max_seed_lookahead; + let min_withdrawability_delay = self + .beacon_node + .harness + .spec + .min_validator_withdrawability_delay; + + // As per the spec: + // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_activation_exit_epoch + let beacon_exit_epoch = current_epoch + 1 + max_seed_lookahead; + let beacon_withdrawable_epoch = beacon_exit_epoch + min_withdrawability_delay; + + assert!(validator_exit_epoch + .iter() + .all(|&epoch| epoch == beacon_exit_epoch)); + + assert!(validator_withdrawable_epoch + .iter() + .all(|&epoch| epoch == beacon_withdrawable_epoch)); + + if result.is_ok() { + 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 exit_single_validator() { + TestBuilder::new() + .await + .with_validators(vec![0]) + .await + .run_test() + .await + .assert_ok(); + } + + #[tokio::test] + async fn exit_multiple_validators() { + TestBuilder::new() + .await + .with_validators(vec![10, 20, 30]) + .await + .run_test() + .await + .assert_ok(); + } +} diff --git a/validator_manager/src/import_validators.rs b/validator_manager/src/import_validators.rs index 6cfbf7b54ee..e5047f3f37e 100644 --- a/validator_manager/src/import_validators.rs +++ b/validator_manager/src/import_validators.rs @@ -404,8 +404,12 @@ pub mod tests { } pub async fn new_with_http_config(http_config: HttpConfig) -> Self { - let dir = tempdir().unwrap(); let vc = ApiTester::new_with_http_config(http_config).await; + Self::new_with_vc(vc).await + } + + pub async fn new_with_vc(vc: ApiTester) -> Self { + let dir = tempdir().unwrap(); let vc_token_path = dir.path().join(VC_TOKEN_FILE_NAME); fs::write(&vc_token_path, &vc.api_token).unwrap(); diff --git a/validator_manager/src/lib.rs b/validator_manager/src/lib.rs index 9beccd3bde2..fb747793049 100644 --- a/validator_manager/src/lib.rs +++ b/validator_manager/src/lib.rs @@ -9,6 +9,7 @@ use types::EthSpec; pub mod common; pub mod create_validators; pub mod delete_validators; +pub mod exit_validators; pub mod import_validators; pub mod list_validators; pub mod move_validators; @@ -51,6 +52,7 @@ pub fn cli_app() -> Command { .subcommand(move_validators::cli_app()) .subcommand(list_validators::cli_app()) .subcommand(delete_validators::cli_app()) + .subcommand(exit_validators::cli_app()) } /// Run the account manager, returning an error if the operation did not succeed. @@ -79,11 +81,14 @@ pub fn run(matches: &ArgMatches, env: Environment) -> Result<(), move_validators::cli_run(matches, dump_config).await } Some((list_validators::CMD, matches)) => { - list_validators::cli_run(matches, dump_config).await + list_validators::cli_run::(matches, dump_config).await } Some((delete_validators::CMD, matches)) => { delete_validators::cli_run(matches, dump_config).await } + Some((exit_validators::CMD, matches)) => { + exit_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.", diff --git a/validator_manager/src/list_validators.rs b/validator_manager/src/list_validators.rs index a0a1c5fb400..6016b89eea1 100644 --- a/validator_manager/src/list_validators.rs +++ b/validator_manager/src/list_validators.rs @@ -1,14 +1,20 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use eth2::lighthouse_vc::types::SingleKeystoreResponse; -use eth2::SensitiveUrl; +use eth2::types::{ConfigAndPreset, StateId, ValidatorId, ValidatorStatus}; +use eth2::{BeaconNodeHttpClient, SensitiveUrl, Timeouts}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::time::Duration; +use types::{ChainSpec, EthSpec, PublicKeyBytes}; +use crate::exit_validators::get_current_epoch; 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 const BEACON_URL_FLAG: &str = "beacon-node"; +pub const VALIDATOR_FLAG: &str = "validators"; pub fn cli_app() -> Command { Command::new(CMD) @@ -31,47 +37,177 @@ pub fn cli_app() -> Command { .action(ArgAction::Set) .display_order(0), ) + .arg( + Arg::new(BEACON_URL_FLAG) + .long(BEACON_URL_FLAG) + .value_name("NETWORK_ADDRESS") + .help( + "Address to a beacon node HTTP API. When supplied, \ + the status of validators (with regard to voluntary exit) \ + will be displayed. This flag is to be used together with \ + the --validators flag.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(VALIDATOR_FLAG), + ) + .arg( + Arg::new(VALIDATOR_FLAG) + .long(VALIDATOR_FLAG) + .value_name("STRING") + .help( + "Comma-separated list of validators (pubkey) to display status for. \ + To display the status for all validators, use the keyword \"all\". \ + This flag is to be used together with the --beacon-node flag.", + ) + .action(ArgAction::Set) + .display_order(0) + .requires(BEACON_URL_FLAG), + ) } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] pub struct ListConfig { pub vc_url: SensitiveUrl, pub vc_token_path: PathBuf, + pub beacon_url: Option, + pub validators_to_display: Vec, } impl ListConfig { fn from_cli(matches: &ArgMatches) -> Result { + let validators_to_display_str = + clap_utils::parse_optional::(matches, VALIDATOR_FLAG)?; + + // Keyword "all" to list all validators, vector to be created later + let validators_to_display = match validators_to_display_str { + Some(str) => { + if str.trim() == "all" { + Vec::new() + } else { + str.split(',') + .map(|s| s.trim().parse()) + .collect::, _>>()? + } + } + None => Vec::new(), + }; + Ok(Self { vc_token_path: clap_utils::parse_required(matches, VC_TOKEN_FLAG)?, vc_url: clap_utils::parse_required(matches, VC_URL_FLAG)?, + beacon_url: clap_utils::parse_optional(matches, BEACON_URL_FLAG)?, + validators_to_display, }) } } -pub async fn cli_run(matches: &ArgMatches, dump_config: DumpConfig) -> Result<(), String> { +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?; + run::(config).await?; Ok(()) } } -async fn run(config: ListConfig) -> Result, String> { +async fn run(config: ListConfig) -> Result, String> { let ListConfig { vc_url, vc_token_path, + beacon_url, + mut validators_to_display, } = 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); + if validators_to_display.is_empty() { + validators_to_display = validators.iter().map(|v| v.validating_pubkey).collect(); } + if let Some(ref beacon_url) = beacon_url { + for validator in &validators_to_display { + let beacon_node = BeaconNodeHttpClient::new( + SensitiveUrl::parse(beacon_url.as_ref()) + .map_err(|e| format!("Failed to parse beacon http server: {:?}", e))?, + Timeouts::set_all(Duration::from_secs(12)), + ); + + let validator_data = beacon_node + .get_beacon_states_validator_id(StateId::Head, &ValidatorId::PublicKey(*validator)) + .await + .map_err(|e| format!("Failed to get updated validator details: {:?}", e))? + .ok_or_else(|| { + format!("Validator {} is not present in the beacon state", validator) + })? + .data; + + match validator_data.status { + ValidatorStatus::ActiveExiting => { + let exit_epoch = validator_data.validator.exit_epoch; + let withdrawal_epoch = validator_data.validator.withdrawable_epoch; + + let genesis_data = beacon_node + .get_beacon_genesis() + .await + .map_err(|e| format!("Failed to get genesis data: {}", e))? + .data; + + let config_and_preset = beacon_node + .get_config_spec::() + .await + .map_err(|e| format!("Failed to get config spec: {}", e))? + .data; + + let spec = ChainSpec::from_config::(config_and_preset.config()) + .ok_or("Failed to create chain spec")?; + + let current_epoch = get_current_epoch::(genesis_data.genesis_time, &spec) + .ok_or("Failed to get current epoch. Please check your system time")?; + + eprintln!( + "Voluntary exit for validator {} has been accepted into the beacon chain. \ + Note that the voluntary exit is subject chain finalization. \ + Before the chain has finalized, there is a low \ + probability that the exit may be reverted.", + validator + ); + eprintln!( + "Current epoch: {}, Exit epoch: {}, Withdrawable epoch: {}", + current_epoch, exit_epoch, withdrawal_epoch + ); + eprintln!("Please keep your validator running till exit epoch"); + eprintln!( + "Exit epoch in approximately {} secs", + (exit_epoch - current_epoch) * spec.seconds_per_slot * E::slots_per_epoch() + ); + } + ValidatorStatus::ExitedSlashed | ValidatorStatus::ExitedUnslashed => { + eprintln!( + "Validator {} has exited at epoch: {}", + validator, validator_data.validator.exit_epoch + ); + } + _ => { + eprintln!( + "Validator {} has not initiated voluntary exit or the voluntary exit \ + is yet to be accepted into the beacon chain. Validator status is: {}", + validator, validator_data.status + ) + } + } + } + } else { + for validator in &validators { + println!("{}", validator.validating_pubkey); + } + } Ok(validators) } @@ -87,7 +223,9 @@ mod test { use crate::{ common::ValidatorSpecification, import_validators::tests::TestBuilder as ImportTestBuilder, }; + use types::MainnetEthSpec; use validator_http_api::{test_utils::ApiTester, Config as HttpConfig}; + type E = MainnetEthSpec; struct TestBuilder { list_config: Option, @@ -116,6 +254,8 @@ mod test { self.list_config = Some(ListConfig { vc_url: builder.get_import_config().vc_url, vc_token_path: builder.get_import_config().vc_token_path, + beacon_url: None, + validators_to_display: vec![], }); self.vc_token = @@ -152,7 +292,7 @@ mod test { .write_all(self.vc_token.clone().unwrap().as_bytes()) .unwrap(); - let result = run(self.list_config.clone().unwrap()).await; + let result = run::(self.list_config.clone().unwrap()).await; if result.is_ok() { let result_ref = result.as_ref().unwrap(); diff --git a/wordlist.txt b/wordlist.txt index 3c7070c6429..ada0384d364 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -196,6 +196,8 @@ pem performant pid pre +presign +presigned pubkey pubkeys rc