From 92609a96e70848f88bfe4aaf483a47d17e1ec79f Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Thu, 5 Nov 2026 15:50:04 -0300 Subject: [PATCH 1/2] Add voluntary exits to the validator client: an `ethlambda validator exit` sub-command that signs one keystore's exit, asks for a confirmation phrase, publishes it to a beacon node and waits for the chain to accept it, plus the keymanager API's POST /eth/v1/validator/{pubkey}/voluntary_exit, which signs without publishing. Exits sign under the capella fork version from deneb on (EIP-7044), keyed on the fork at the exit's epoch as Lighthouse does, so a pre-signed exit stays valid across later forks. The beacon node API gains `validator` (one registry entry with its activation, exit and withdrawable epochs) and `submit_voluntary_exit`, implemented for the HTTP client, failover and the test mock. The keymanager context carries the beacon node, signing context and clock the new route needs. The client's three required flags become `Option`s clap still marks required, because `subcommand_negates_reqs` only relaxes the parser: the derived `from_arg_matches` refused `validator exit` on a plain `PathBuf` field. --- bin/ethlambda/src/command.rs | 77 ++- bin/ethlambda/src/main.rs | 16 +- bin/ethlambda/src/validator.rs | 140 ++++- .../rpc/src/beacon/validator_client_tests.rs | 82 +++ crates/validator/src/beacon_node/dto.rs | 23 +- crates/validator/src/beacon_node/fallback.rs | 16 +- crates/validator/src/beacon_node/http.rs | 57 +- crates/validator/src/beacon_node/mock.rs | 34 +- crates/validator/src/beacon_node/mod.rs | 31 +- crates/validator/src/error.rs | 5 + crates/validator/src/exit.rs | 594 ++++++++++++++++++ crates/validator/src/http_api/keystores.rs | 4 + crates/validator/src/http_api/mod.rs | 41 +- crates/validator/src/http_api/proposer.rs | 18 +- .../validator/src/http_api/voluntary_exit.rs | 309 +++++++++ crates/validator/src/lib.rs | 4 + crates/validator/src/signing.rs | 124 +++- crates/validator/src/slot_clock.rs | 5 + docs/cli.md | 55 +- 19 files changed, 1591 insertions(+), 44 deletions(-) create mode 100644 crates/validator/src/exit.rs create mode 100644 crates/validator/src/http_api/voluntary_exit.rs diff --git a/bin/ethlambda/src/command.rs b/bin/ethlambda/src/command.rs index 7d0b9556a..127b4f939 100644 --- a/bin/ethlambda/src/command.rs +++ b/bin/ethlambda/src/command.rs @@ -83,6 +83,13 @@ pub(crate) enum Command { /// Replay real chain history from `.era` files and check it against the stored states. ReplayEra(ReplayEraOptions), /// Run the beacon-chain validator client against a beacon node's HTTP API. + // + // `subcommand_negates_reqs`: `ethlambda validator exit` carries only its + // own flags, so the client's required ones must not be demanded of it; + // `args_conflicts_with_subcommands`: and none of the client's flags may be + // given alongside it, since none applies. On the variant rather than on + // `ValidatorOptions`, where clap's derive does not pick them up. + #[command(subcommand_negates_reqs = true, args_conflicts_with_subcommands = true)] Validator(crate::validator::ValidatorOptions), /// Generate validator XMSS keys for a genesis. Keygen(KeygenOptions), @@ -198,8 +205,74 @@ mod tests { Command::Validator(options) => options, other => panic!("expected a validator invocation, got {other:?}"), }; - assert_eq!(options.beacon_nodes, vec!["http://localhost:5052"]); - assert_eq!(options.validators_dir, PathBuf::from("/tmp/validators")); + assert_eq!( + options.beacon_nodes.as_deref(), + Some(&["http://localhost:5052".to_string()][..]) + ); + assert_eq!( + options.validators_dir, + Some(PathBuf::from("/tmp/validators")) + ); + assert!(options.command.is_none()); + } + + /// Without the sub-command the client's flags stay required: a bare + /// `ethlambda validator` must not start a client with nothing configured. + #[test] + fn a_client_run_still_requires_its_flags() { + let args = ["ethlambda", "validator", "--graffiti", "hello"]; + assert!(try_parse_from(args.iter().map(OsString::from)).is_err()); + } + + /// `validator exit` carries only its own flags: the client's required + /// ones are not demanded of it. + #[test] + fn the_validator_exit_subcommand_parses_without_the_client_flags() { + let args = [ + "ethlambda", + "validator", + "exit", + "--keystore", + "/tmp/keystore.json", + "--password-file", + "/tmp/password", + "--beacon-node", + "http://localhost:5052", + "--presign", + "--epoch", + "123", + ]; + let command = try_parse_from(args.iter().map(OsString::from)).expect("invocation parses"); + let Command::Validator(options) = command else { + panic!("expected a validator invocation, got {command:?}"); + }; + let Some(crate::validator::ValidatorCommand::Exit(exit)) = options.command else { + panic!("expected the exit sub-command, got {:?}", options.command); + }; + assert_eq!(exit.keystore, PathBuf::from("/tmp/keystore.json")); + assert_eq!(exit.password_file, PathBuf::from("/tmp/password")); + assert_eq!(exit.beacon_node, "http://localhost:5052"); + assert_eq!(exit.epoch, Some(123)); + assert!(exit.presign); + assert!(!exit.no_confirmation); + assert!(!exit.no_wait); + } + + /// And the client's own flags are refused beside it, since none applies. + #[test] + fn the_client_flags_are_refused_beside_validator_exit() { + let args = [ + "ethlambda", + "validator", + "--graffiti", + "hello", + "exit", + "--keystore", + "/tmp/keystore.json", + "--password-file", + "/tmp/password", + ]; + assert!(try_parse_from(args.iter().map(OsString::from)).is_err()); } /// The historical flat form must keep working, unchanged. diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 9110da5de..a7f5690d6 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -105,10 +105,18 @@ fn main() -> eyre::Result<()> { init_info_stderr_logging()?; era::run(options) } - Command::Validator(options) => { - init_node_logging()?; - validator::run(options) - } + Command::Validator(mut options) => match options.command.take() { + // A one-shot command whose stdout may carry a pre-signed exit, so + // its logs go to stderr like keygen's. + Some(validator::ValidatorCommand::Exit(exit)) => { + init_info_stderr_logging()?; + validator::exit(exit) + } + None => { + init_node_logging()?; + validator::run(options) + } + }, // Key generation is synchronous, CPU-bound work whose product is files, // so it runs on this thread too and leaves stdout alone. Command::Keygen(options) => { diff --git a/bin/ethlambda/src/validator.rs b/bin/ethlambda/src/validator.rs index f97f82782..c5d0dd17a 100644 --- a/bin/ethlambda/src/validator.rs +++ b/bin/ethlambda/src/validator.rs @@ -1,37 +1,51 @@ -//! `ethlambda validator`: the beacon-chain validator client. +//! `ethlambda validator`: the beacon-chain validator client, and +//! `ethlambda validator exit`, which signs and publishes one validator's +//! voluntary exit. //! -//! It talks to a beacon node over the standard REST Beacon API and depends on -//! nothing else in this binary, so it runs against any conformant node. +//! Both talk to a beacon node over the standard REST Beacon API and depend on +//! nothing else in this binary, so they run against any conformant node. use std::path::PathBuf; use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, H160}; use ethlambda_validator::ValidatorConfig; +use ethlambda_validator::exit::{self, CONFIRMATION_PHRASE, ExitConfig, Outcome}; use ethlambda_validator::proposer_settings::{GRAFFITI_BYTES, graffiti_from_text}; /// An execution address is exactly this wide. const ADDRESS_BYTES: usize = 20; +// The three required flags are `Option`s that clap still marks `required`. +// `subcommand_negates_reqs` on `Command::Validator` lets `validator exit` omit +// them, and clap's derive builds this struct for that invocation too, which a +// plain `PathBuf` field refuses with its own "required argument was not +// provided". Clap still refuses a client run without them, so `None` never +// reaches [`run`]; [`ValidatorOptions::required`] says so rather than unwrap. #[derive(Debug, clap::Args)] pub(crate) struct ValidatorOptions { + /// `None` runs the client. See `Command::Validator` for why the client's + /// required flags are not demanded of, nor accepted beside, a sub-command. + #[command(subcommand)] + pub(crate) command: Option, + /// Base URL(s) of the beacon node's HTTP API (e.g. http://localhost:5052). /// /// Several may be supplied, comma-separated or by repeating the flag. They /// are tried in order and the first that answers is used, so the order is /// a preference, not a load-balancing policy. #[arg(long, value_delimiter = ',', required = true)] - pub(crate) beacon_nodes: Vec, + pub(crate) beacon_nodes: Option>, /// Directory holding the EIP-2335 keystores and `validator_definitions.yml`. /// Without that file, keystores in the Lighthouse layout /// (`<0xpubkey>/voting-keystore.json`, password in /// `--secrets-dir/<0xpubkey>`) are discovered and the file is written. - #[arg(long)] - pub(crate) validators_dir: PathBuf, + #[arg(long, required = true)] + pub(crate) validators_dir: Option, /// Directory holding one password file per keystore. - #[arg(long)] - pub(crate) secrets_dir: PathBuf, + #[arg(long, required = true)] + pub(crate) secrets_dir: Option, /// Bind address for the metrics and keymanager servers. #[arg(long, default_value = "127.0.0.1")] @@ -80,7 +94,66 @@ pub(crate) struct ValidatorOptions { pub(crate) enable_keymanager: bool, } +/// What `ethlambda validator` can do besides run as a client. +#[derive(Debug, clap::Subcommand)] +pub(crate) enum ValidatorCommand { + /// Sign a voluntary exit for one validator and publish it to a beacon node. + /// + /// Irreversible once the chain processes it. Asks for a confirmation phrase + /// before publishing unless --no-confirmation is given. + Exit(ExitOptions), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExitOptions { + /// Path to the validator's EIP-2335 voting keystore. + #[arg(long)] + pub(crate) keystore: PathBuf, + + /// Path to the file holding the keystore's password. + #[arg(long)] + pub(crate) password_file: PathBuf, + + /// Base URL of the beacon node's HTTP API. + #[arg(long, default_value = "http://localhost:5052")] + pub(crate) beacon_node: String, + + /// The exit's epoch. Defaults to the current one. A later epoch only makes + /// sense with --presign, since a node refuses an exit whose epoch has not + /// come. + #[arg(long)] + pub(crate) epoch: Option, + + /// Sign the exit and print it as JSON on stdout without publishing it. + #[arg(long, default_value = "false")] + pub(crate) presign: bool, + + /// Publish without asking for the confirmation phrase. Use with care. + #[arg(long, default_value = "false")] + pub(crate) no_confirmation: bool, + + /// Return as soon as the beacon node has accepted the exit, without + /// waiting for the chain to process it. + #[arg(long, default_value = "false")] + pub(crate) no_wait: bool, +} + impl ValidatorOptions { + /// The flags clap requires of a client run, which only `validator exit` + /// may omit (see the struct's comment). A `None` here is a bug in the + /// command-line wiring, reported as such rather than unwrapped. + fn required(self) -> eyre::Result<(Vec, PathBuf, PathBuf)> { + match (self.beacon_nodes, self.validators_dir, self.secrets_dir) { + (Some(beacon_nodes), Some(validators_dir), Some(secrets_dir)) => { + Ok((beacon_nodes, validators_dir, secrets_dir)) + } + _ => eyre::bail!( + "--beacon-nodes, --validators-dir and --secrets-dir are required to run the \ + validator client" + ), + } + } + /// The graffiti bytes, or an error naming the limit if the text is too /// long. /// @@ -144,30 +217,61 @@ pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> { options.validate_ports()?; let graffiti = options.graffiti()?; let suggested_fee_recipient = options.suggested_fee_recipient()?; + let metrics = std::net::SocketAddr::new(options.http_address, options.metrics_port); + let keymanager = options + .enable_keymanager + .then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port)); + let (beacon_nodes, validators_dir, secrets_dir) = options.required()?; ethlambda_validator::run(ValidatorConfig { - beacon_nodes: options.beacon_nodes, + beacon_nodes, graffiti, suggested_fee_recipient, - validators_dir: options.validators_dir, - secrets_dir: options.secrets_dir, - metrics: std::net::SocketAddr::new(options.http_address, options.metrics_port), - keymanager: options - .enable_keymanager - .then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port)), + validators_dir, + secrets_dir, + metrics, + keymanager, }) .await .map_err(Into::into) } +/// Sign and publish one validator's voluntary exit, on its own runtime like +/// [`run`]. A run the operator aborted at the confirmation prompt is a clean +/// exit, not an error: nothing happened, and nothing needed to. +#[tokio::main] +pub(crate) async fn exit(options: ExitOptions) -> eyre::Result<()> { + let outcome = exit::run(ExitConfig { + keystore: options.keystore, + password_file: options.password_file, + beacon_node: options.beacon_node, + options: exit::ExitOptions { + epoch: options.epoch, + presign: options.presign, + no_confirmation: options.no_confirmation, + no_wait: options.no_wait, + }, + }) + .await?; + match outcome { + Outcome::Presigned(signed) => exit::print_presigned(&signed)?, + Outcome::NotConfirmed => { + eprintln!("Nothing was published. The phrase is \"{CONFIRMATION_PHRASE}\"."); + } + Outcome::Published { .. } => {} + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; fn options(graffiti: &str) -> ValidatorOptions { ValidatorOptions { - beacon_nodes: vec!["http://localhost:5052".to_string()], - validators_dir: PathBuf::from("/tmp/validators"), - secrets_dir: PathBuf::from("/tmp/secrets"), + command: None, + beacon_nodes: Some(vec!["http://localhost:5052".to_string()]), + validators_dir: Some(PathBuf::from("/tmp/validators")), + secrets_dir: Some(PathBuf::from("/tmp/secrets")), http_address: "127.0.0.1".parse().expect("valid address"), keymanager_port: 5062, metrics_port: 5064, diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index 693450ebf..80ac3c201 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -41,6 +41,7 @@ use ethlambda_validator::{ }, http::HttpBeaconNode, }, + exit::{ExitOptions, Outcome, run_with as run_exit}, keys::ValidatorStore, signing::SigningContext, sync_committee::{SyncCommitteeService, subnets_of}, @@ -196,6 +197,87 @@ fn keys_for(state: &BeaconState, indices: &[u64]) -> RwLock { RwLock::new(keys) } +/// `ethlambda validator exit`, end to end: the client's own lookup of the +/// validator, its signature, and this node's pool endpoint. The fixture's +/// validators are all active since epoch 0 and the wall epoch is far past +/// `SHARD_COMMITTEE_PERIOD`, so the node's gossip rules accept the exit. +#[tokio::test] +async fn the_validator_client_can_exit_a_validator_through_this_node() { + use ethlambda_types::beacon::{ + containers::shared::{SignedVoluntaryExit, VoluntaryExit}, + operation::BeaconOperation, + }; + + let (client, state, network) = serve().await; + let pubkey = state.validator(0).unwrap().pubkey; + + let details = client + .validator(&pubkey) + .await + .expect("the node answers") + .expect("validator 0 is in the state"); + assert_eq!(details.index, 0); + assert_eq!(details.status, "active_ongoing"); + assert_eq!( + details.activation_epoch, + state.validator(0).unwrap().activation_epoch + ); + assert_eq!(details.exit_epoch, state.validator(0).unwrap().exit_epoch); + assert!( + client + .validator(&BlsPubkey([9; 48])) + .await + .expect("a 404 is an answer, not an error") + .is_none(), + "a key the chain does not know is None" + ); + + // The whole command, minus the prompt, against this node over the socket. + let keys = { + let mut store = ValidatorStore::new(); + store + .insert_secret("test", &secret_key_for(0).to_bytes()) + .expect("valid key"); + store + }; + let options = ExitOptions { + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + let mut no_input = std::io::empty(); + let mut no_input = std::io::BufReader::new(&mut no_input); + let outcome = run_exit(&client, &keys, pubkey, &options, &mut no_input) + .await + .expect("the node accepts the exit"); + assert_eq!(outcome, Outcome::Published { accepted: None }); + + let published = network.operations.lock().unwrap().clone(); + let [BeaconOperation::VoluntaryExit(exit)] = published.as_slice() else { + panic!("expected one published exit, got {published:?}"); + }; + assert_eq!(exit.message.validator_index, 0); + assert_eq!(exit.message.epoch, compute_epoch_at_slot(state.slot())); + + // A refusal comes back as the node's 400 with its reason, not as a + // transport failure. + let forged = SignedVoluntaryExit { + message: VoluntaryExit { + epoch: exit.message.epoch, + validator_index: 1, + }, + signature: exit.signature, + }; + let err = client + .submit_voluntary_exit(&forged) + .await + .expect_err("validator 0's signature does not cover validator 1's exit"); + assert!( + matches!(&err, Error::BeaconNodeStatus { status: 400, body } if body.contains("voluntary exit")), + "got {err:?}" + ); +} + /// One slot of an attester's work, in the order `ethlambda validator` does it. /// /// The node has an execution client, since it refuses attestation data diff --git a/crates/validator/src/beacon_node/dto.rs b/crates/validator/src/beacon_node/dto.rs index d072748f5..798089535 100644 --- a/crates/validator/src/beacon_node/dto.rs +++ b/crates/validator/src/beacon_node/dto.rs @@ -20,7 +20,7 @@ //! | `AttesterDuty` | pubkey, validator_index, committee_index, committee_length, committees_at_slot, validator_committee_index, slot | all seven, all used | //! | Genesis | genesis_time, genesis_validators_root, genesis_fork_version | the first two; the fork version comes from `/config/spec` | //! | Syncing | head_slot, sync_distance, is_syncing, is_optimistic, el_offline | is_syncing and is_optimistic gate signing; el_offline is logged | -//! | `ValidatorResponse` | index, balance, status, validator | index, status, validator.pubkey | +//! | `ValidatorResponse` | index, balance, status, validator | index, status, validator.pubkey; the single-validator form also reads the activation, exit and withdrawable epochs | //! | `ProposerDuty` | pubkey, validator_index, slot | all three, all used | //! | `ProposerPreparation` | validator_index, fee_recipient | both, sent not read | //! @@ -292,6 +292,27 @@ pub struct ValidatorInnerDto { pub pubkey: String, } +/// `GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}`: one +/// `ValidatorResponse`, with the epochs a voluntary exit is checked against. +#[derive(Debug, Clone, Deserialize)] +pub struct ValidatorDetailsDto { + #[serde(with = "quoted_u64")] + pub index: ValidatorIndex, + pub status: String, + pub validator: ValidatorDetailsInnerDto, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ValidatorDetailsInnerDto { + pub pubkey: String, + #[serde(with = "quoted_u64")] + pub activation_epoch: Epoch, + #[serde(with = "quoted_u64")] + pub exit_epoch: Epoch, + #[serde(with = "quoted_u64")] + pub withdrawable_epoch: Epoch, +} + /// What a validator client submits for one attester duty, from Electra on. #[derive(Debug, Clone, Serialize)] pub struct SingleAttestationDto { diff --git a/crates/validator/src/beacon_node/fallback.rs b/crates/validator/src/beacon_node/fallback.rs index 8251f9559..dd4e93eb4 100644 --- a/crates/validator/src/beacon_node/fallback.rs +++ b/crates/validator/src/beacon_node/fallback.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::gloas; -use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::containers::shared::{AttestationData, SignedVoluntaryExit}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; use tracing::{debug, warn}; @@ -23,7 +23,7 @@ use crate::beacon_node::dto::{ }; use crate::beacon_node::{ AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties, - PtcDuties, Published, SignedAggregates, ValidatorEntry, + PtcDuties, Published, SignedAggregates, ValidatorDetails, ValidatorEntry, }; use crate::error::{Error, Result}; @@ -428,6 +428,18 @@ impl BeaconNodeApi for FallbackBeaconNode { }) .await } + + async fn validator(&self, pubkey: &BlsPubkey) -> Result> { + self.try_each("validator", |node| node.validator(pubkey)) + .await + } + + async fn submit_voluntary_exit(&self, exit: &SignedVoluntaryExit) -> Result<()> { + self.try_each("submit_voluntary_exit", |node| { + node.submit_voluntary_exit(exit) + }) + .await + } } #[cfg(test)] diff --git a/crates/validator/src/beacon_node/http.rs b/crates/validator/src/beacon_node/http.rs index 013964c74..c1378e65c 100644 --- a/crates/validator/src/beacon_node/http.rs +++ b/crates/validator/src/beacon_node/http.rs @@ -27,7 +27,7 @@ use ethlambda_types::beacon::config::Config; use ethlambda_types::beacon::containers::altair; use ethlambda_types::beacon::containers::electra::Attestation; use ethlambda_types::beacon::containers::gloas; -use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::containers::shared::{AttestationData, SignedVoluntaryExit}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; use reqwest::{Client, StatusCode}; @@ -41,12 +41,13 @@ use crate::beacon_node::dto::{ AttestationDataDto, AttestationDto, AttesterDutyDto, BlockRootResponse, CommitteeSubscriptionDto, DataResponse, DutiesResponse, GenesisDto, IndexedErrorResponse, ProposerDutyDto, ProposerPreparationDto, PtcDutyDto, SignedAggregateAndProofOutDto, - SingleAttestationDto, SyncCommitteeSubscriptionDto, SyncDutyDto, SyncingDto, ValidatorEntryDto, - VersionedResponse, config_from_spec_response, encode_hex, parse_pubkey, parse_root, + SingleAttestationDto, SyncCommitteeSubscriptionDto, SyncDutyDto, SyncingDto, + ValidatorDetailsDto, ValidatorEntryDto, VersionedResponse, config_from_spec_response, + encode_hex, parse_pubkey, parse_root, }; use crate::beacon_node::{ AggregateAttestation, AggregateKind, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, - ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorEntry, + ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorDetails, ValidatorEntry, validate_attestation_data, validate_produced_block, validate_sync_contribution, }; use crate::error::{BeaconNodeFailure, Error, Result}; @@ -1047,6 +1048,54 @@ impl BeaconNodeApi for HttpBeaconNode { ) .await } + + async fn validator(&self, pubkey: &BlsPubkey) -> Result> { + let url = format!( + "{}/eth/v1/beacon/states/head/validators/{}", + self.base_url, + encode_hex(&pubkey.0) + ); + debug!(%url, "Beacon API GET"); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|err| Error::BeaconNode { + url: url.clone(), + failure: BeaconNodeFailure::classify(&err), + detail: err.to_string(), + })?; + // `get_optional` reads a 204 as "nothing"; this endpoint says it with + // a 404, which the specification lists as "validator not found". + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let response: DataResponse = Self::decode(response).await?; + let entry = response.data; + // The answer names the key it describes; a node answering about + // another validator must not have its index signed into an exit. + let answered = parse_pubkey(&entry.validator.pubkey)?; + if answered != *pubkey { + return Err(Error::InconsistentResponse(format!( + "asked about validator {}, node answered about {}", + encode_hex(&pubkey.0), + entry.validator.pubkey + ))); + } + Ok(Some(ValidatorDetails { + index: entry.index, + status: entry.status, + activation_epoch: entry.validator.activation_epoch, + exit_epoch: entry.validator.exit_epoch, + withdrawable_epoch: entry.validator.withdrawable_epoch, + })) + } + + async fn submit_voluntary_exit(&self, exit: &SignedVoluntaryExit) -> Result<()> { + self.post_no_content("/eth/v1/beacon/pool/voluntary_exits", exit, None) + .await + } } #[cfg(test)] diff --git a/crates/validator/src/beacon_node/mock.rs b/crates/validator/src/beacon_node/mock.rs index fe4a952b9..161b59365 100644 --- a/crates/validator/src/beacon_node/mock.rs +++ b/crates/validator/src/beacon_node/mock.rs @@ -11,6 +11,7 @@ use ethlambda_types::beacon::containers::electra::{Attestation, SignedAggregateA use ethlambda_types::beacon::containers::gloas; use ethlambda_types::beacon::containers::shared::AttestationData; use ethlambda_types::beacon::containers::shared::Checkpoint; +use ethlambda_types::beacon::containers::shared::SignedVoluntaryExit; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex}; @@ -24,7 +25,7 @@ use crate::beacon_node::dto::{ }; use crate::beacon_node::{ AggregateAttestation, AggregateKind, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, - ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorEntry, + ProposerDuties, PtcDuties, Published, SignedAggregates, ValidatorDetails, ValidatorEntry, validate_sync_contribution, }; use crate::error::{BeaconNodeFailure, Error, Result}; @@ -132,6 +133,10 @@ pub struct MockBeaconNode { pub contribution_requests: Mutex>, pub published_contributions: Mutex>, pub sync_subscriptions: Mutex>, + /// What `validator` answers for any key. `None` is the node's 404. Behind + /// a mutex so a test can flip the status while the exit command waits. + pub validator_details: Mutex>, + pub submitted_exits: Mutex>, } impl MockBeaconNode { @@ -408,6 +413,19 @@ impl MockBeaconNode { self.sync_subscriptions.lock().expect("lock").clone() } + pub fn with_validator_details(self, details: ValidatorDetails) -> Self { + self.set_validator_details(Some(details)); + self + } + + pub fn set_validator_details(&self, details: Option) { + *self.validator_details.lock().expect("lock") = details; + } + + pub fn submitted_exits(&self) -> Vec { + self.submitted_exits.lock().expect("lock").clone() + } + pub fn with_publish_outcome(mut self, outcome: Published) -> Self { self.publish_outcome = Some(outcome); self @@ -944,4 +962,18 @@ impl BeaconNodeApi for MockBeaconNode { .extend_from_slice(subscriptions); Ok(()) } + + async fn validator(&self, _pubkey: &BlsPubkey) -> Result> { + self.guard("validator")?; + Ok(self.validator_details.lock().expect("lock").clone()) + } + + async fn submit_voluntary_exit(&self, exit: &SignedVoluntaryExit) -> Result<()> { + self.guard("submit_voluntary_exit")?; + self.submitted_exits + .lock() + .expect("lock") + .push(exit.clone()); + Ok(()) + } } diff --git a/crates/validator/src/beacon_node/mod.rs b/crates/validator/src/beacon_node/mod.rs index c689d2a1d..9d44a40b6 100644 --- a/crates/validator/src/beacon_node/mod.rs +++ b/crates/validator/src/beacon_node/mod.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use ethlambda_types::beacon::config::Config; -use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::containers::shared::{AttestationData, SignedVoluntaryExit}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{ BlsPubkey, BlsSignature, Bytes32, Epoch, Root, Slot, ValidatorIndex, @@ -241,6 +241,21 @@ pub struct ValidatorEntry { pub status: String, } +/// One validator's registry entry, as much of it as a voluntary exit needs. +/// +/// `status` is the Beacon API's own string (`active_ongoing`, +/// `active_exiting`, `exited_unslashed`, ...). The three epochs are what the +/// exit command checks eligibility against before signing and reports once the +/// chain has accepted the exit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatorDetails { + pub index: ValidatorIndex, + pub status: String, + pub activation_epoch: Epoch, + pub exit_epoch: Epoch, + pub withdrawable_epoch: Epoch, +} + #[async_trait] pub trait BeaconNodeApi: Send + Sync { /// The chain's genesis time and validators root, needed to compute slots @@ -549,6 +564,20 @@ pub trait BeaconNodeApi: Send + Sync { &self, subscriptions: &[SyncCommitteeSubscriptionDto], ) -> Result<()>; + + /// One validator's registry entry at the node's head, or `None` when the + /// node knows no validator with that key (404), which is an ordinary + /// answer for a key whose deposit has not been processed, not an error. + /// + /// `GET /eth/v1/beacon/states/head/validators/{pubkey}`. Unlike + /// [`Self::validator_indices`] this carries the activation and exit + /// epochs, which is what a voluntary exit is checked against. + async fn validator(&self, pubkey: &BlsPubkey) -> Result>; + + /// Hand a signed voluntary exit to the node's pool, which validates it + /// against its head state and gossips it. A refusal (400) surfaces as + /// [`crate::error::Error::BeaconNodeStatus`] carrying the node's reason. + async fn submit_voluntary_exit(&self, exit: &SignedVoluntaryExit) -> Result<()>; } /// Check that `contribution` is the one asked for, as the contract on diff --git a/crates/validator/src/error.rs b/crates/validator/src/error.rs index 55b82a0ff..207691d07 100644 --- a/crates/validator/src/error.rs +++ b/crates/validator/src/error.rs @@ -83,6 +83,10 @@ pub enum Error { ProposalRefused { slot: u64, reason: String }, #[error("beacon node returned an inconsistent response: {0}")] InconsistentResponse(String), + /// `ethlambda validator exit` refused to sign: the chain would refuse the + /// exit too, for the reason given. Deterministic, like a proposal refusal. + #[error("voluntary exit refused: {0}")] + ExitRefused(String), #[error("malformed response: {0}")] Decode(String), @@ -131,6 +135,7 @@ impl Error { // A refusal is deterministic: the guard will refuse the same slot // again, so retrying only repeats it. Self::ProposalRefused { .. } + | Self::ExitRefused(_) | Self::Keystore { .. } | Self::KeystoreVersion(_) | Self::KeystoreBadPassword diff --git a/crates/validator/src/exit.rs b/crates/validator/src/exit.rs new file mode 100644 index 000000000..81c6d341a --- /dev/null +++ b/crates/validator/src/exit.rs @@ -0,0 +1,594 @@ +//! `ethlambda validator exit`: sign a voluntary exit for one keystore and hand +//! it to a beacon node. +//! +//! A voluntary exit is the one thing a validator signs that cannot be taken +//! back: once the chain processes it the validator is queued to leave, stops +//! earning once it has, and the key can never be re-activated. So the command +//! is deliberately slow to pull the trigger. It refuses a node that is still +//! syncing, refuses a validator the chain would refuse (not active, or not yet +//! past `SHARD_COMMITTEE_PERIOD` since activation), signs, and then, unless +//! told not to, asks for a confirmation phrase before publishing anything. +//! +//! `--presign` stops after signing and prints the `SignedVoluntaryExit` as JSON +//! instead, for an operator who wants to hold a signed exit for later. EIP-7044 +//! made that useful: from deneb on, exits sign under a fixed fork version, so +//! one signed today still verifies after every fork to come. +//! +//! The command talks to one beacon node. It is not a long-running client, so +//! failover buys it nothing: an operator whose node is down picks another. + +use std::io::BufRead; +use std::path::{Path, PathBuf}; + +use ethlambda_types::beacon::containers::shared::{SignedVoluntaryExit, VoluntaryExit}; +use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; +use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch}; +use tracing::info; + +use crate::beacon_node::dto::encode_hex; +use crate::beacon_node::http::HttpBeaconNode; +use crate::beacon_node::{BeaconNodeApi, ValidatorDetails}; +use crate::error::{Error, Result}; +use crate::keys::ValidatorStore; +use crate::keys::keystore::Keystore; +use crate::signing::SigningContext; +use crate::slot_clock::SlotClock; + +/// What the operator must type to publish. The same phrase Lighthouse asks +/// for, so an operator moving between the two has one thing to remember. +pub const CONFIRMATION_PHRASE: &str = "Exit my validator"; + +/// Everything the command is configured with. +#[derive(Debug, Clone)] +pub struct ExitConfig { + /// The EIP-2335 voting keystore of the validator to exit. + pub keystore: PathBuf, + /// The file holding that keystore's password. + pub password_file: PathBuf, + /// Base URL of the beacon node's HTTP API. + pub beacon_node: String, + pub options: ExitOptions, +} + +/// The flags that shape the run, apart from where the key and the node are. +#[derive(Debug, Clone, Default)] +pub struct ExitOptions { + /// The exit's epoch. `None` means the current one. A later epoch only makes + /// sense with `presign`: a node refuses an exit whose epoch has not come. + pub epoch: Option, + /// Sign and print the exit as JSON without publishing it. + pub presign: bool, + /// Publish without asking for [`CONFIRMATION_PHRASE`]. + pub no_confirmation: bool, + /// Return as soon as the node has accepted the exit, without waiting for + /// the chain to process it. + pub no_wait: bool, +} + +/// How a run ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// `presign`: the signed exit, which nobody has published. + Presigned(SignedVoluntaryExit), + /// The operator did not type the phrase, so nothing was published. + NotConfirmed, + /// The node accepted the exit for gossip. `accepted` is what the chain + /// then said about the validator, or `None` under `no_wait`. + Published { accepted: Option }, +} + +/// Decrypt the keystore and run the exit against the configured node. Input +/// for the confirmation phrase is the process's stdin. +pub async fn run(config: ExitConfig) -> Result { + let (store, pubkey) = load_keystore(&config.keystore, &config.password_file)?; + let node = HttpBeaconNode::new(&config.beacon_node)?; + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + run_with(&node, &store, pubkey, &config.options, &mut input).await +} + +/// Decrypt one keystore into a store holding only its key. +/// +/// The password file is read the way `ValidatorStore::load` reads one: whole, +/// with a trailing newline trimmed, since an editor leaves one and a password +/// ending in a newline is not something anyone chose. +pub fn load_keystore( + keystore_path: &Path, + password_path: &Path, +) -> Result<(ValidatorStore, BlsPubkey)> { + let json = std::fs::read_to_string(keystore_path).map_err(|source| Error::Io { + path: keystore_path.display().to_string(), + source, + })?; + let keystore = Keystore::from_json(&json).map_err(|err| Error::Keystore { + path: keystore_path.display().to_string(), + reason: err.to_string(), + })?; + let password = std::fs::read_to_string(password_path).map_err(|source| Error::Io { + path: password_path.display().to_string(), + source, + })?; + let secret = keystore + .decrypt(password.trim_end_matches(['\n', '\r'])) + .map_err(|err| Error::Keystore { + path: keystore_path.display().to_string(), + reason: err.to_string(), + })?; + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret(&keystore_path.display().to_string(), &secret)?; + Ok((store, pubkey)) +} + +/// The run itself, against any node and any source of the confirmation +/// phrase, which is what the tests drive. +pub async fn run_with( + node: &B, + store: &ValidatorStore, + pubkey: BlsPubkey, + options: &ExitOptions, + input: &mut dyn BufRead, +) -> Result { + let genesis = node.genesis().await?; + let spec = node.spec().await?; + let clock = SlotClock::from_config(genesis.genesis_time, &spec); + let signing = SigningContext { + config: spec, + genesis_validators_root: genesis.genesis_validators_root, + }; + + // A syncing node's registry is behind the chain's, so its answer about + // the validator's status and index is not one to sign over. + if node.is_optimistic_or_syncing().await? { + return Err(Error::BeaconNodeSyncing); + } + + let current_epoch = current_epoch(&clock)?; + let epoch = options.epoch.unwrap_or(current_epoch); + let details = eligible_for_exit(node, &pubkey, epoch, &signing).await?; + + let exit = VoluntaryExit { + epoch, + validator_index: details.index, + }; + let signature = signing.sign_voluntary_exit(store, &pubkey, &exit)?; + let signed = SignedVoluntaryExit { + message: exit, + signature, + }; + + if options.presign { + info!( + pubkey = %encode_hex(&pubkey.0), + validator_index = exit.validator_index, + epoch, + "Pre-signed a voluntary exit; nothing was published" + ); + return Ok(Outcome::Presigned(signed)); + } + + if !options.no_confirmation { + eprintln!(); + eprintln!( + "Publishing a voluntary exit for validator {} (index {}) at epoch {epoch}.", + encode_hex(&pubkey.0), + exit.validator_index + ); + eprintln!(); + eprintln!("WARNING: THIS IS AN IRREVERSIBLE OPERATION."); + eprintln!( + "Once the chain processes it the validator is queued to exit, stops earning after \ + its exit epoch, and this key can never validate again." + ); + eprintln!(); + eprintln!( + "Type the phrase \"{CONFIRMATION_PHRASE}\" to confirm, or anything else to abort:" + ); + let mut line = String::new(); + input.read_line(&mut line).map_err(|source| Error::Io { + path: "stdin".to_string(), + source, + })?; + if line.trim() != CONFIRMATION_PHRASE { + eprintln!( + "Did not publish the voluntary exit for validator {}: the phrase did not match.", + encode_hex(&pubkey.0) + ); + return Ok(Outcome::NotConfirmed); + } + } + + node.submit_voluntary_exit(&signed).await?; + eprintln!( + "The beacon node accepted the voluntary exit for validator {} and is gossiping it.", + encode_hex(&pubkey.0) + ); + + if options.no_wait { + return Ok(Outcome::Published { accepted: None }); + } + + let accepted = wait_for_acceptance(node, &pubkey, &clock).await?; + Ok(Outcome::Published { + accepted: Some(accepted), + }) +} + +fn current_epoch(clock: &SlotClock) -> Result { + let slot = clock.now().ok_or_else(|| { + Error::InconsistentResponse( + "the beacon node reports a genesis in the future; check the system clock".to_string(), + ) + })?; + Ok(clock.epoch_of(slot)) +} + +/// The validator's registry entry, if the chain would accept an exit for it +/// at `epoch`: active, not already exiting, and past its shard committee +/// period. The same gate `process_voluntary_exit` applies, checked here so +/// the refusal names the reason instead of arriving as a node's 400. +async fn eligible_for_exit( + node: &B, + pubkey: &BlsPubkey, + epoch: Epoch, + signing: &SigningContext, +) -> Result { + let Some(details) = node.validator(pubkey).await? else { + return Err(Error::ExitRefused(format!( + "validator {} is not in the beacon state; check that the node is synced and the \ + validator's deposit has been processed", + encode_hex(&pubkey.0) + ))); + }; + if details.status != "active_ongoing" { + return Err(Error::ExitRefused(format!( + "validator {} is not eligible for a voluntary exit: its status is {}", + encode_hex(&pubkey.0), + details.status + ))); + } + let eligible_epoch = details + .activation_epoch + .checked_add(signing.config.shard_committee_period) + .ok_or_else(|| { + Error::InconsistentResponse(format!( + "validator {} has an activation epoch too high to add SHARD_COMMITTEE_PERIOD to", + encode_hex(&pubkey.0) + )) + })?; + if epoch < eligible_epoch { + return Err(Error::ExitRefused(format!( + "validator {} is not eligible for a voluntary exit before epoch {eligible_epoch} \ + (activated at epoch {}, and SHARD_COMMITTEE_PERIOD is {} epochs)", + encode_hex(&pubkey.0), + details.activation_epoch, + signing.config.shard_committee_period + ))); + } + Ok(details) +} + +/// Poll the validator's status once a slot until the chain has processed the +/// exit, reporting what it said. +async fn wait_for_acceptance( + node: &B, + pubkey: &BlsPubkey, + clock: &SlotClock, +) -> Result { + let slot_duration = clock.slot_duration(); + loop { + tokio::time::sleep(slot_duration).await; + let Some(details) = node.validator(pubkey).await? else { + return Err(Error::InconsistentResponse(format!( + "validator {} vanished from the beacon state while waiting for its exit", + encode_hex(&pubkey.0) + ))); + }; + match details.status.as_str() { + "active_exiting" => { + let current_epoch = current_epoch(clock)?; + let epochs_to_go = details.exit_epoch.saturating_sub(current_epoch); + let seconds_to_go = slot_duration + .saturating_mul(epochs_to_go.saturating_mul(SLOTS_PER_EPOCH) as u32) + .as_secs(); + eprintln!( + "The chain has accepted the voluntary exit, but it is not finalized yet. \ + Finalization may take several minutes or longer, and before it there is a \ + small chance the exit is reverted." + ); + eprintln!( + "Current epoch: {current_epoch}, exit epoch: {}, withdrawable epoch: {}", + details.exit_epoch, details.withdrawable_epoch + ); + eprintln!( + "Keep the validator running until its exit epoch, in roughly {seconds_to_go} \ + seconds." + ); + return Ok(details); + } + "exited_unslashed" | "exited_slashed" => { + eprintln!("The validator exited at epoch {}.", details.exit_epoch); + return Ok(details); + } + _ => { + eprintln!("Waiting for the chain to accept the voluntary exit..."); + } + } + } +} + +/// Print a pre-signed exit the way the command does: as pretty JSON on stdout +/// and nothing else there, so it pipes into a file or another tool. +pub fn print_presigned(signed: &SignedVoluntaryExit) -> Result<()> { + let json = serde_json::to_string_pretty(signed) + .map_err(|err| Error::Decode(format!("could not encode the signed exit: {err}")))?; + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::constants::FAR_FUTURE_EPOCH; + use ethlambda_types::beacon::primitives::Root; + + use super::*; + use crate::beacon_node::Genesis; + use crate::beacon_node::mock::MockBeaconNode; + + const INDEX: u64 = 77; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + fn store_with_key() -> (ValidatorStore, BlsPubkey) { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + (store, pubkey) + } + + /// Genesis far enough back that the current epoch clears mainnet's + /// `SHARD_COMMITTEE_PERIOD` for a validator activated at epoch 0. + fn genesis() -> Genesis { + let config = Config::mainnet(); + let epochs_back = config.shard_committee_period + 10; + let seconds_back = epochs_back * SLOTS_PER_EPOCH * config.slot_duration_ms / 1000; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after the epoch") + .as_secs(); + Genesis { + genesis_time: now - seconds_back, + genesis_validators_root: Root::repeat_byte(5), + } + } + + fn active(activation_epoch: Epoch) -> ValidatorDetails { + ValidatorDetails { + index: INDEX, + status: "active_ongoing".to_string(), + activation_epoch, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + } + } + + fn node_with(details: ValidatorDetails) -> MockBeaconNode { + MockBeaconNode { + genesis: Some(genesis()), + ..MockBeaconNode::new() + } + .with_validator_details(details) + } + + fn phrase() -> Cursor { + Cursor::new(format!("{CONFIRMATION_PHRASE}\n")) + } + + async fn run(node: &MockBeaconNode, options: ExitOptions, input: &str) -> Result { + let (store, pubkey) = store_with_key(); + let mut input = Cursor::new(input.to_string()); + run_with(node, &store, pubkey, &options, &mut input).await + } + + #[tokio::test] + async fn a_syncing_node_is_refused_before_anything_is_signed() { + let node = MockBeaconNode { + syncing: true, + ..node_with(active(0)) + }; + let err = run(&node, ExitOptions::default(), "") + .await + .expect_err("must refuse"); + assert!(matches!(err, Error::BeaconNodeSyncing), "got {err:?}"); + assert!(node.submitted_exits().is_empty()); + } + + #[tokio::test] + async fn a_validator_the_chain_does_not_know_is_refused() { + let node = MockBeaconNode { + genesis: Some(genesis()), + ..MockBeaconNode::new() + }; + let err = run(&node, ExitOptions::default(), "") + .await + .expect_err("must refuse"); + assert!( + matches!(&err, Error::ExitRefused(reason) if reason.contains("not in the beacon state")), + "got {err:?}" + ); + } + + #[tokio::test] + async fn a_validator_that_is_not_active_is_refused() { + let node = node_with(ValidatorDetails { + status: "pending_queued".to_string(), + ..active(0) + }); + let err = run(&node, ExitOptions::default(), "") + .await + .expect_err("must refuse"); + assert!( + matches!(&err, Error::ExitRefused(reason) if reason.contains("pending_queued")), + "got {err:?}" + ); + assert!(node.submitted_exits().is_empty()); + } + + /// The chain refuses an exit before `activation_epoch + + /// SHARD_COMMITTEE_PERIOD`; so does this, naming the epoch it becomes + /// possible at, which a node's 400 would not. + #[tokio::test] + async fn a_validator_inside_its_shard_committee_period_is_refused() { + let activation = 5; + let node = node_with(active(activation)); + let far_future = ExitOptions { + epoch: Some(activation + 1), + presign: true, + ..ExitOptions::default() + }; + let err = run(&node, far_future, "").await.expect_err("must refuse"); + let eligible = activation + Config::mainnet().shard_committee_period; + assert!( + matches!(&err, Error::ExitRefused(reason) if reason.contains(&format!("before epoch {eligible}"))), + "got {err:?}" + ); + } + + #[tokio::test] + async fn presign_returns_a_verifying_exit_and_publishes_nothing() { + use blst::min_pk::{PublicKey, Signature}; + + let node = node_with(active(0)); + let (store, pubkey) = store_with_key(); + let options = ExitOptions { + presign: true, + epoch: Some(1_000_000), + ..ExitOptions::default() + }; + let outcome = run_with(&node, &store, pubkey, &options, &mut phrase()) + .await + .expect("signs"); + let Outcome::Presigned(signed) = outcome else { + panic!("expected a presigned exit, got {outcome:?}"); + }; + assert_eq!(signed.message.epoch, 1_000_000); + assert_eq!(signed.message.validator_index, INDEX); + assert!( + node.submitted_exits().is_empty(), + "presign must not publish" + ); + + let signing = SigningContext { + config: Config::mainnet(), + genesis_validators_root: genesis().genesis_validators_root, + }; + let root = signing.voluntary_exit_signing_root(&signed.message); + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&signed.signature.0).expect("valid signature"); + assert_eq!( + sig.verify( + true, + root.as_slice(), + b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", + &[], + &pk, + true + ), + blst::BLST_ERROR::BLST_SUCCESS + ); + } + + #[tokio::test] + async fn a_wrong_phrase_publishes_nothing() { + let node = node_with(active(0)); + let outcome = run(&node, ExitOptions::default(), "exit my validator\n") + .await + .expect("an aborted run is not an error"); + assert_eq!(outcome, Outcome::NotConfirmed); + assert!(node.submitted_exits().is_empty()); + } + + #[tokio::test] + async fn the_phrase_publishes_and_no_wait_returns_at_once() { + let node = node_with(active(0)); + let options = ExitOptions { + no_wait: true, + ..ExitOptions::default() + }; + let outcome = run(&node, options, &format!("{CONFIRMATION_PHRASE}\n")) + .await + .expect("publishes"); + assert_eq!(outcome, Outcome::Published { accepted: None }); + let submitted = node.submitted_exits(); + assert_eq!(submitted.len(), 1); + assert_eq!(submitted[0].message.validator_index, INDEX); + } + + #[tokio::test] + async fn no_confirmation_skips_the_prompt() { + let node = node_with(active(0)); + let options = ExitOptions { + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + let outcome = run(&node, options, "").await.expect("publishes"); + assert_eq!(outcome, Outcome::Published { accepted: None }); + assert_eq!(node.submitted_exits().len(), 1); + } + + /// The exit epoch defaults to the current one, by the client's clock. + #[tokio::test] + async fn the_exit_epoch_defaults_to_the_current_epoch() { + let node = node_with(active(0)); + let options = ExitOptions { + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + run(&node, options, "").await.expect("publishes"); + let clock = SlotClock::from_config(genesis().genesis_time, &Config::mainnet()); + let now = clock.epoch_of(clock.now().expect("after genesis")); + assert_eq!(node.submitted_exits()[0].message.epoch, now); + } + + /// With `start_paused`, the once-a-slot sleep completes at once, so the + /// wait loop is exercised without waiting a slot. + #[tokio::test(start_paused = true)] + async fn the_wait_ends_once_the_chain_reports_the_validator_exiting() { + let node = node_with(active(0)); + let (store, pubkey) = store_with_key(); + let exiting = ValidatorDetails { + status: "active_exiting".to_string(), + exit_epoch: 1_000_000, + withdrawable_epoch: 1_000_256, + ..active(0) + }; + let options = ExitOptions { + no_confirmation: true, + ..ExitOptions::default() + }; + // The exit is checked against the active entry; the poll then sees + // the exiting one. + let mut input = phrase(); + let run = run_with(&node, &store, pubkey, &options, &mut input); + tokio::pin!(run); + let flip = async { + tokio::task::yield_now().await; + node.set_validator_details(Some(exiting.clone())); + }; + let (outcome, ()) = tokio::join!(run, flip); + assert_eq!( + outcome.expect("published and accepted"), + Outcome::Published { + accepted: Some(exiting) + } + ); + } +} diff --git a/crates/validator/src/http_api/keystores.rs b/crates/validator/src/http_api/keystores.rs index 2232679b3..1a081c12e 100644 --- a/crates/validator/src/http_api/keystores.rs +++ b/crates/validator/src/http_api/keystores.rs @@ -555,6 +555,7 @@ mod tests { const PASSWORD: &str = "\u{1d531}\u{1d522}\u{1d530}\u{1d531}\u{1d52d}\u{1d51e}\u{1d530}\u{1d530}\u{1d534}\u{1d52c}\u{1d52f}\u{1d521}\u{1f511}"; fn context_with_dirs(store: SharedStore, dir: &std::path::Path) -> KeymanagerContext { + let (beacon_node, signing, clock) = crate::http_api::tests::exit_route_context(); KeymanagerContext { store, settings: Arc::new(crate::proposer_settings::ProposerSettings::new( @@ -564,6 +565,9 @@ mod tests { validators_dir: dir.to_path_buf(), secrets_dir: dir.to_path_buf(), definitions_lock: Arc::new(tokio::sync::Mutex::new(())), + beacon_node, + signing, + clock, } } diff --git a/crates/validator/src/http_api/mod.rs b/crates/validator/src/http_api/mod.rs index 26e250b6e..b7b816f6d 100644 --- a/crates/validator/src/http_api/mod.rs +++ b/crates/validator/src/http_api/mod.rs @@ -12,18 +12,22 @@ use axum::extract::Request; use axum::http::StatusCode; use axum::middleware::{self, Next}; use axum::response::Response; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Extension, Router}; use subtle::ConstantTimeEq as _; use tokio::sync::Mutex; use tracing::info; use crate::Error; +use crate::beacon_node::BeaconNodeApi; use crate::proposer_settings::ProposerSettings; use crate::secure_fs; +use crate::signing::SigningContext; +use crate::slot_clock::SlotClock; pub mod keystores; pub mod proposer; +pub mod voluntary_exit; pub use keystores::SharedStore; @@ -58,6 +62,13 @@ pub struct KeymanagerContext { /// it takes this one first and `store` second, consistently, so the two /// never deadlock against each other. pub definitions_lock: Arc>, + /// Where the `voluntary_exit` route resolves a key's validator index. + pub beacon_node: Arc, + /// What the `voluntary_exit` route signs with. + pub signing: Arc, + /// What the `voluntary_exit` route reads the current epoch from when the + /// caller names none. + pub clock: SlotClock, } /// The file the generated token is written to, inside the validators directory. @@ -152,6 +163,10 @@ pub fn router(context: KeymanagerContext, token: String) -> Router { .post(proposer::set_gas_limit) .delete(proposer::delete_gas_limit), ) + .route( + "/eth/v1/validator/{pubkey}/voluntary_exit", + post(voluntary_exit::create_signed_voluntary_exit), + ) .with_state(context) .layer(middleware::from_fn(require_bearer)) .layer(Extension(ApiToken(token))) @@ -208,8 +223,30 @@ async fn require_bearer(request: Request, next: Next) -> Response { } } +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// The three `voluntary_exit` fields for a test that is not about that + /// route: a mock node resolving no validator, mainnet's schedule and a + /// clock started at the Unix epoch. + pub(crate) fn exit_route_context() -> (Arc, Arc, SlotClock) { + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::primitives::Root; + + let config = Config::mainnet(); + let clock = SlotClock::from_config(0, &config); + let signing = Arc::new(SigningContext { + config, + genesis_validators_root: Root::ZERO, + }); + let node = Arc::new(crate::beacon_node::mock::MockBeaconNode::new()); + (node, signing, clock) + } +} + #[cfg(all(test, unix))] -mod tests { +mod token_tests { use super::*; use std::os::unix::fs::PermissionsExt as _; diff --git a/crates/validator/src/http_api/proposer.rs b/crates/validator/src/http_api/proposer.rs index beec84d3c..e94cf795e 100644 --- a/crates/validator/src/http_api/proposer.rs +++ b/crates/validator/src/http_api/proposer.rs @@ -101,12 +101,12 @@ pub struct SetGasLimitRequest { } /// The key in the path, or a 400 when it is not one. -fn parse_path_key(text: &str) -> Result { +pub(crate) fn parse_path_key(text: &str) -> Result { parse_pubkey(text).map_err(|err| ApiError::BadRequest(err.to_string())) } /// A 404 unless the store holds `pubkey`. -fn require_held(store: &ValidatorStore, pubkey: &BlsPubkey) -> Result<(), ApiError> { +pub(crate) fn require_held(store: &ValidatorStore, pubkey: &BlsPubkey) -> Result<(), ApiError> { if store.contains(pubkey) { Ok(()) } else { @@ -137,17 +137,18 @@ fn parse_address(text: &str) -> Result { /// A decimal `u64` with no sign and no leading zero, the specification's /// `Uint64` pattern. Stricter than `str::parse`, which takes a leading `+`. -fn parse_uint64(text: &str) -> Result { +/// `field` names the value in the 400. +pub(crate) fn parse_uint64(field: &str, text: &str) -> Result { let well_formed = !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) && (text == "0" || !text.starts_with('0')); if !well_formed { return Err(ApiError::BadRequest(format!( - "gas_limit {text:?} is not a decimal unsigned integer" + "{field} {text:?} is not a decimal unsigned integer" ))); } text.parse() - .map_err(|_| ApiError::BadRequest(format!("gas_limit {text} does not fit in 64 bits"))) + .map_err(|_| ApiError::BadRequest(format!("{field} {text} does not fit in 64 bits"))) } pub async fn get_fee_recipient( @@ -292,7 +293,7 @@ pub async fn set_gas_limit( ) -> Result { let pubkey = parse_path_key(&pubkey)?; let Json(request) = body?; - let gas_limit = parse_uint64(&request.gas_limit)?; + let gas_limit = parse_uint64("gas_limit", &request.gas_limit)?; let store = context.store.read().await; require_held(&store, &pubkey)?; @@ -330,6 +331,7 @@ mod tests { use super::*; use crate::http_api::router; + use crate::http_api::tests::exit_route_context; use crate::proposer_settings::{DEFAULT_GAS_LIMIT, ProposerSettings}; const TOKEN: &str = "test-token"; @@ -361,12 +363,16 @@ mod tests { let pubkey = store.insert_secret("test", &secret()).expect("inserts"); let settings = Arc::new(ProposerSettings::new(default_graffiti(), fee_recipient)); let dir = tempfile::tempdir().expect("temp dir"); + let (beacon_node, signing, clock) = exit_route_context(); let context = KeymanagerContext { store: Arc::new(RwLock::new(store)), settings: settings.clone(), validators_dir: dir.path().to_path_buf(), secrets_dir: dir.path().to_path_buf(), definitions_lock: Arc::new(tokio::sync::Mutex::new(())), + beacon_node, + signing, + clock, }; Harness { app: router(context, TOKEN.to_string()), diff --git a/crates/validator/src/http_api/voluntary_exit.rs b/crates/validator/src/http_api/voluntary_exit.rs new file mode 100644 index 000000000..305314774 --- /dev/null +++ b/crates/validator/src/http_api/voluntary_exit.rs @@ -0,0 +1,309 @@ +//! `POST /eth/v1/validator/{pubkey}/voluntary_exit`. +//! +//! Signs a voluntary exit for one validator this client holds and answers +//! with the `SignedVoluntaryExit`. It does **not** publish it: the keymanager +//! specification leaves that to the caller, who hands the message to a beacon +//! node's `POST /eth/v1/beacon/pool/voluntary_exits`. That split is what lets +//! an operator pre-sign an exit for a later epoch and hold on to it. +//! +//! The exit epoch is the `epoch` query parameter, or the current epoch by the +//! client's own clock when none is given. The validator index is asked of the +//! beacon node on every call rather than cached: a key whose deposit has not +//! been processed has no index yet, and a 404 saying so is the honest answer +//! until it does. +//! +//! Every route answers 404 for a key this client does not hold, as the +//! settings routes do. + +use axum::Json; +use axum::extract::{Path, Query, State}; +use ethlambda_types::beacon::containers::shared::{SignedVoluntaryExit, VoluntaryExit}; +use serde::Deserialize; +use tracing::info; + +use crate::beacon_node::dto::encode_hex; +use crate::http_api::KeymanagerContext; +use crate::http_api::proposer::{ApiError, Data, parse_path_key, parse_uint64, require_held}; + +#[derive(Debug, Deserialize)] +pub struct VoluntaryExitQuery { + /// The specification's `Uint64`: a decimal string, which [`parse_uint64`] + /// checks the shape of. Read as text so a malformed value is this + /// module's 400 rather than axum's own rejection. + pub epoch: Option, +} + +pub async fn create_signed_voluntary_exit( + State(context): State, + Path(pubkey): Path, + Query(query): Query, +) -> Result>, ApiError> { + let pubkey = parse_path_key(&pubkey)?; + let epoch = match &query.epoch { + Some(text) => parse_uint64("epoch", text)?, + None => { + let slot = context.clock.now().ok_or_else(|| { + ApiError::Internal("the chain's genesis is in the future".to_string()) + })?; + context.clock.epoch_of(slot) + } + }; + + // The key is checked before the beacon node is asked, so a key this + // client does not hold costs no round trip and is answered the same way + // whether or not a node is reachable. + require_held(&*context.store.read().await, &pubkey)?; + + let resolved = context + .beacon_node + .validator_indices(&[pubkey]) + .await + .map_err(|err| { + ApiError::Internal(format!("could not resolve the validator index: {err}")) + })?; + let Some(entry) = resolved.into_iter().find(|entry| entry.pubkey == pubkey) else { + return Err(ApiError::NotFound(format!( + "the validator index for {} is not known yet; the beacon node has no \ + validator with that key, so its deposit may not have been processed", + encode_hex(&pubkey.0) + ))); + }; + + let exit = VoluntaryExit { + epoch, + validator_index: entry.index, + }; + // Signed under the store's read lock, like the settings routes change + // their setting under it: a keystore delete, which takes the write lock, + // cannot land between the check above and the signature. + let store = context.store.read().await; + let signature = context + .signing + .sign_voluntary_exit(&store, &pubkey, &exit) + .map_err(|err| match err { + crate::Error::UnknownValidator(_) => ApiError::NotFound(format!( + "no validator with pubkey {} is held by this client", + encode_hex(&pubkey.0) + )), + other => ApiError::Internal(format!("signing failed: {other}")), + })?; + drop(store); + + info!( + pubkey = %encode_hex(&pubkey.0), + validator_index = exit.validator_index, + epoch, + "Signed a voluntary exit; it is not published until the caller submits it" + ); + Ok(Json(Data { + data: SignedVoluntaryExit { + message: exit, + signature, + }, + })) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use ethlambda_types::beacon::config::Config; + use ethlambda_types::beacon::primitives::{BlsPubkey, BlsSignature, Bytes32, Root}; + use http_body_util::BodyExt as _; + use tokio::sync::RwLock; + use tower::ServiceExt as _; + + use super::*; + use crate::beacon_node::ValidatorEntry; + use crate::beacon_node::mock::MockBeaconNode; + use crate::http_api::router; + use crate::keys::ValidatorStore; + use crate::proposer_settings::ProposerSettings; + use crate::signing::SigningContext; + use crate::slot_clock::SlotClock; + + const TOKEN: &str = "test-token"; + const INDEX: u64 = 1234; + + fn secret() -> [u8; 32] { + hex::decode("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f") + .expect("valid hex") + .try_into() + .expect("32 bytes") + } + + struct Harness { + app: axum::Router, + pubkey: BlsPubkey, + signing: Arc, + clock: SlotClock, + _dir: tempfile::TempDir, + } + + /// A router over a store holding one key, a beacon node that resolves it + /// to `INDEX` when `resolves` is set, and a clock a few epochs past + /// genesis. + fn harness(resolves: bool) -> Harness { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_secret("test", &secret()).expect("inserts"); + let mut node = MockBeaconNode::new(); + if resolves { + node.validators = vec![ValidatorEntry { + index: INDEX, + pubkey, + status: "active_ongoing".to_string(), + }]; + } + let config = Config::mainnet(); + let genesis_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after the epoch") + .as_secs() + - 10 * 32 * 12; + let clock = SlotClock::from_config(genesis_time, &config); + let signing = Arc::new(SigningContext { + config, + genesis_validators_root: Root::repeat_byte(3), + }); + let dir = tempfile::tempdir().expect("temp dir"); + let context = KeymanagerContext { + store: Arc::new(RwLock::new(store)), + settings: Arc::new(ProposerSettings::new(Bytes32::ZERO, None)), + validators_dir: dir.path().to_path_buf(), + secrets_dir: dir.path().to_path_buf(), + definitions_lock: Arc::new(tokio::sync::Mutex::new(())), + beacon_node: Arc::new(node), + signing: signing.clone(), + clock: clock.clone(), + }; + Harness { + app: router(context, TOKEN.to_string()), + pubkey, + signing, + clock, + _dir: dir, + } + } + + fn uri(pubkey: &BlsPubkey, query: &str) -> String { + format!( + "/eth/v1/validator/{}/voluntary_exit{query}", + encode_hex(&pubkey.0) + ) + } + + fn post(uri: &str, token: Option<&str>) -> Request { + let mut builder = Request::builder().method("POST").uri(uri); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + builder.body(Body::empty()).expect("request") + } + + async fn send(app: &axum::Router, request: Request) -> (StatusCode, serde_json::Value) { + let response = app.clone().oneshot(request).await.expect("responds"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn verify(pubkey: &BlsPubkey, signature: &BlsSignature, root: Root) -> bool { + use blst::min_pk::{PublicKey, Signature}; + const DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + + let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); + let sig = Signature::from_bytes(&signature.0).expect("valid signature"); + sig.verify(true, root.as_slice(), DST, &[], &pk, true) == blst::BLST_ERROR::BLST_SUCCESS + } + + #[tokio::test] + async fn a_signed_exit_for_the_current_epoch_is_returned_and_verifies() { + let harness = harness(true); + let (status, body) = send(&harness.app, post(&uri(&harness.pubkey, ""), Some(TOKEN))).await; + assert_eq!(status, StatusCode::OK, "got {body}"); + + let signed: SignedVoluntaryExit = + serde_json::from_value(body["data"].clone()).expect("a SignedVoluntaryExit"); + let now = harness.clock.now().expect("after genesis"); + assert_eq!(signed.message.epoch, harness.clock.epoch_of(now)); + assert_eq!(signed.message.validator_index, INDEX); + assert!(verify( + &harness.pubkey, + &signed.signature, + harness.signing.voluntary_exit_signing_root(&signed.message) + )); + // The wire form quotes its integers, as every Beacon API container does. + assert!(body["data"]["message"]["epoch"].is_string(), "got {body}"); + assert!( + body["data"]["message"]["validator_index"].is_string(), + "got {body}" + ); + } + + #[tokio::test] + async fn the_epoch_query_parameter_is_honoured() { + let harness = harness(true); + let request = post(&uri(&harness.pubkey, "?epoch=999999"), Some(TOKEN)); + let (status, body) = send(&harness.app, request).await; + assert_eq!(status, StatusCode::OK, "got {body}"); + assert_eq!(body["data"]["message"]["epoch"], "999999"); + } + + #[tokio::test] + async fn a_malformed_epoch_is_a_bad_request() { + let harness = harness(true); + for epoch in ["abc", "-1", "+5", "05", ""] { + let request = post( + &uri(&harness.pubkey, &format!("?epoch={epoch}")), + Some(TOKEN), + ); + let (status, body) = send(&harness.app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{epoch:?}: {body}"); + } + } + + #[tokio::test] + async fn a_key_this_client_does_not_hold_is_not_found() { + let harness = harness(true); + let stranger = BlsPubkey([9; 48]); + let (status, _) = send(&harness.app, post(&uri(&stranger, ""), Some(TOKEN))).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn a_key_with_no_index_yet_is_not_found() { + let harness = harness(false); + let (status, body) = send(&harness.app, post(&uri(&harness.pubkey, ""), Some(TOKEN))).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert!( + body["message"] + .as_str() + .is_some_and(|message| message.contains("not known yet")), + "got {body}" + ); + } + + #[tokio::test] + async fn a_malformed_pubkey_is_a_bad_request() { + let harness = harness(true); + let request = post("/eth/v1/validator/0x1234/voluntary_exit", Some(TOKEN)); + let (status, _) = send(&harness.app, request).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + /// The blanket bearer layer covers this route too. + #[tokio::test] + async fn the_route_without_the_token_is_rejected() { + let harness = harness(true); + let (status, _) = send(&harness.app, post(&uri(&harness.pubkey, ""), None)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/validator/src/lib.rs b/crates/validator/src/lib.rs index ebec73b1f..d1602265b 100644 --- a/crates/validator/src/lib.rs +++ b/crates/validator/src/lib.rs @@ -36,6 +36,7 @@ pub mod attestation_guard; pub mod beacon_node; pub mod duties; pub mod error; +pub mod exit; pub mod http_api; pub mod keys; pub mod metrics; @@ -212,6 +213,9 @@ pub async fn run(config: ValidatorConfig) -> Result<()> { validators_dir: config.validators_dir.clone(), secrets_dir: config.secrets_dir.clone(), definitions_lock: Arc::new(Mutex::new(())), + beacon_node: beacon_node.clone(), + signing: context.clone(), + clock: clock.clone(), }; let router = http_api::router(context, token); let listener = tokio::net::TcpListener::bind(address) diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs index ced20d40e..6dd4b66e6 100644 --- a/crates/validator/src/signing.rs +++ b/crates/validator/src/signing.rs @@ -1,5 +1,6 @@ //! Producing the signatures a validator owes: attestations, the RANDAO reveal -//! a block carries, the block itself, and the two an aggregator needs. +//! a block carries, the block itself, the two an aggregator needs, and the one +//! an operator asks for when a validator leaves: the voluntary exit. //! //! The signing root always comes from the SSZ container, never from the JSON a //! beacon node sent: the wire representation is a transport detail, and the @@ -10,6 +11,7 @@ use ethlambda_types::beacon::constants::{ DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_BUILDER, DOMAIN_BEACON_PROPOSER, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, + DOMAIN_VOLUNTARY_EXIT, }; use ethlambda_types::beacon::containers::altair::{ ContributionAndProof, SyncAggregatorSelectionData, @@ -17,7 +19,8 @@ use ethlambda_types::beacon::containers::altair::{ use ethlambda_types::beacon::containers::gloas::{ ExecutionPayloadEnvelope, PayloadAttestationData, }; -use ethlambda_types::beacon::containers::shared::AttestationData; +use ethlambda_types::beacon::containers::shared::{AttestationData, VoluntaryExit}; +use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{ BLS_SIGNATURE_SIZE, BlsPubkey, BlsSignature, Domain, DomainType, Epoch, HashTreeRoot as _, Root, Slot, @@ -192,6 +195,36 @@ impl SigningContext { compute_signing_root(message.hash_tree_root(), domain) } + /// The root a voluntary exit's signature is computed over. + /// + /// The domain does not follow the fork schedule the way every other + /// signature here does. EIP-7044, part of deneb, pinned exits to + /// `CAPELLA_FORK_VERSION` for good: a `process_voluntary_exit` on any + /// deneb-or-later state verifies under the capella version whatever the + /// exit's own epoch names, so an exit signed now stays valid across every + /// fork to come. The point was pre-signed exits, which a staking service + /// hands its customer so they can leave without the service's cooperation; + /// a domain that moved with the schedule made those expire at each fork. + /// + /// Before deneb the rule was the ordinary one, the fork version at the + /// exit's epoch. The switch is keyed on the fork at that epoch, as + /// Lighthouse keys it, which gives the same answer as the processing + /// state's fork for every exit a live network can still process. + pub fn voluntary_exit_signing_root(&self, exit: &VoluntaryExit) -> Root { + let fork = self.config.fork_at_epoch(exit.epoch); + let fork_version = if fork >= ForkName::Deneb { + self.config.capella_fork_version + } else { + self.config.fork_version(fork) + }; + let domain = compute_domain( + DOMAIN_VOLUNTARY_EXIT, + fork_version, + self.genesis_validators_root, + ); + compute_signing_root(exit.hash_tree_root(), domain) + } + /// Sign an already-computed signing root on behalf of `pubkey`. /// /// Every public signing method funnels through here, so there is one place @@ -361,6 +394,23 @@ impl SigningContext { ) } + /// Sign a voluntary exit on behalf of `pubkey`. + /// + /// Not slashable, and not guarded, but the one signature here that is + /// **irreversible**: once the chain processes it the validator is on its + /// way out, and nothing it signs afterwards brings it back. The guard is + /// the caller's: the command line asks for a confirmation phrase, and the + /// keymanager route is bearer-authenticated and only ever returns the + /// signed message to its caller, which is what then decides to publish. + pub fn sign_voluntary_exit( + &self, + store: &ValidatorStore, + pubkey: &BlsPubkey, + exit: &VoluntaryExit, + ) -> Result { + self.sign_root(store, pubkey, self.voluntary_exit_signing_root(exit)) + } + /// Sign the block whose root is `block_root`, proposed for `slot`, on /// behalf of `pubkey`. /// @@ -759,6 +809,76 @@ mod tests { )); } + #[test] + fn a_voluntary_exit_verifies_under_its_own_root() { + let (store, pubkey) = store_with_key(); + let context = context(); + let exit = VoluntaryExit { + epoch: context.config.deneb_fork_epoch + 10, + validator_index: 42, + }; + + let signature = context + .sign_voluntary_exit(&store, &pubkey, &exit) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.voluntary_exit_signing_root(&exit) + )); + } + + /// EIP-7044: from deneb on, an exit signs under the capella fork version + /// and not the version of the fork its epoch is in. A client that used + /// the schedule's version here would produce exits every deneb-or-later + /// node rejects, and the operator would learn that from the node's 400. + #[test] + fn an_exit_at_or_after_deneb_signs_under_the_capella_fork_version() { + let context = context(); + let far_beyond_deneb = context.config.electra_fork_epoch + 1_000; + for epoch in [context.config.deneb_fork_epoch, far_beyond_deneb] { + let exit = VoluntaryExit { + epoch, + validator_index: 1, + }; + let expected = compute_signing_root( + exit.hash_tree_root(), + compute_domain( + DOMAIN_VOLUNTARY_EXIT, + context.config.capella_fork_version, + context.genesis_validators_root, + ), + ); + assert_eq!(context.voluntary_exit_signing_root(&exit), expected); + assert_ne!( + expected, + compute_signing_root( + exit.hash_tree_root(), + context.domain(DOMAIN_VOLUNTARY_EXIT, epoch) + ), + "the fixture must cross a fork the schedule's version differs at" + ); + } + } + + /// Before deneb the ordinary rule held: the version of the fork at the + /// exit's epoch. + #[test] + fn an_exit_before_deneb_signs_under_its_epochs_fork_version() { + let context = context(); + let exit = VoluntaryExit { + epoch: context.config.altair_fork_epoch + 1, + validator_index: 1, + }; + assert_eq!( + context.voluntary_exit_signing_root(&exit), + compute_signing_root( + exit.hash_tree_root(), + context.domain(DOMAIN_VOLUNTARY_EXIT, exit.epoch) + ) + ); + } + #[test] fn signing_a_randao_reveal_for_an_unknown_validator_is_an_error() { let store = ValidatorStore::new(); diff --git a/crates/validator/src/slot_clock.rs b/crates/validator/src/slot_clock.rs index 28aeb3dbc..2178a625f 100644 --- a/crates/validator/src/slot_clock.rs +++ b/crates/validator/src/slot_clock.rs @@ -110,6 +110,11 @@ impl SlotClock { slot / SLOTS_PER_EPOCH } + /// How long one slot lasts. + pub fn slot_duration(&self) -> Duration { + Duration::from_millis(self.slot_duration_ms) + } + /// When `slot` begins. pub fn start_of(&self, slot: Slot) -> SystemTime { // Saturating rather than wrapping: a slot number large enough to diff --git a/docs/cli.md b/docs/cli.md index 2dcbf6703..2c6bd91a7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -286,6 +286,50 @@ implementation's, so none of the [common flags](#common-flags) apply to it. | `--graffiti` | empty | Text for the graffiti field of proposed blocks, for every validator the keymanager API gives no graffiti of its own. At most 32 bytes as UTF-8, right-padded with zeros. Refused rather than truncated if longer. An ethlambda beacon node appends both clients' versions to it (see [`rpc.md`](rpc.md#validator-endpoints)) | | `--enable-keymanager` | off | Serve the keymanager API: keystores, plus each validator's own fee recipient, graffiti and gas limit. Off by default because it mutates key material | +### `validator exit`: leaving the validator set + +`ethlambda validator exit` signs a voluntary exit for one validator and hands +it to a beacon node, which validates it against its head and gossips it. It +takes only its own flags; none of the client's flags above apply to it. + +```sh +ethlambda validator exit \ + --keystore ~/validators/0xabc.../voting-keystore.json \ + --password-file ~/secrets/0xabc... \ + --beacon-node http://localhost:5052 +``` + +| Flag | Default | Meaning | +|---|---|---| +| `--keystore` | required | The validator's EIP-2335 voting keystore | +| `--password-file` | required | The file holding that keystore's password. A trailing newline is ignored | +| `--beacon-node` | `http://localhost:5052` | Base URL of the beacon node's HTTP API. One node, not a list: this is a one-shot command, and an operator whose node is down picks another | +| `--epoch` | the current epoch | The exit's epoch. A later one only makes sense with `--presign`, since a node refuses an exit whose epoch has not come | +| `--presign` | off | Sign the exit and print it as JSON on stdout without publishing it, for an operator who wants to hold a signed exit for later. Logs go to stderr, so the JSON pipes cleanly into a file | +| `--no-confirmation` | off | Publish without asking for the confirmation phrase. For scripts; use with care | +| `--no-wait` | off | Return as soon as the beacon node has accepted the exit, without waiting for the chain to process it | + +**A voluntary exit is irreversible.** Once the chain processes it the +validator is queued to leave, stops earning after its exit epoch, and the key +can never validate again. So before publishing anything the command refuses a +beacon node that is still syncing, refuses a validator the chain would refuse +(one that is not `active_ongoing`, or not yet `SHARD_COMMITTEE_PERIOD` epochs +past its activation, naming the epoch it becomes eligible at), and then prints +a warning and asks for the phrase `Exit my validator` on stdin. Anything else +aborts without publishing. + +After publishing, unless `--no-wait` is given, it polls the validator's status +once a slot. When the chain reports `active_exiting` it prints the current, +exit and withdrawable epochs and how long until the exit epoch, and returns; +**keep the validator running until that epoch**, since it still has duties +until then. A validator already `exited_unslashed` or `exited_slashed` is +reported as such. + +From deneb on, exits are signed under `CAPELLA_FORK_VERSION` whatever their +epoch (EIP-7044), so a pre-signed exit stays valid across every fork to come. +The command signs that way, and the keymanager API's `voluntary_exit` route +below does too. + ### What it does today Attestations, block proposals and attestation aggregation, and from gloas on @@ -405,7 +449,16 @@ Off unless `--enable-keymanager` is passed. It serves `GET`, `POST` and if absent. The token file is written `0600` and a file too short to be a real token is refused rather than accepted. -It also serves `GET`, `POST` and `DELETE` on +It also serves `POST /eth/v1/validator/{pubkey}/voluntary_exit`, which signs a +voluntary exit for a validator this client holds and answers with the +`SignedVoluntaryExit`, for the exit's `epoch` query parameter or the current +epoch when none is given. **It does not publish the exit.** As the +specification has it, the caller hands the message to a beacon node's +`POST /eth/v1/beacon/pool/voluntary_exits`, or holds on to it. The validator +index comes from the beacon node on each call, so a key whose deposit the chain +has not processed yet is a `404` saying so. + +And it serves `GET`, `POST` and `DELETE` on `/eth/v1/validator/{pubkey}/feerecipient`, `/graffiti` and `/gas_limit`, which give one validator its own value in place of the command line's default: From 888bafa6d79834e9d84a4c6ecee0e6dd0dc39b59 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 11 Nov 2026 17:34:32 -0300 Subject: [PATCH 2/2] Sign voluntary exits for the fork that processes them, and address the rest of the review The signing domain was keyed on the fork at the exit's own epoch. A node verifies against the state that processes the exit: any deneb-or-later state uses the capella version whatever epoch the exit names (EIP-7044), so `--epoch 0`, or `?epoch=0` on the keymanager route, produced an exit every node on a chain past deneb rejects. The domain now follows the fork at max(current_epoch, exit.epoch), the earliest epoch a state can process the exit in, and before deneb mirrors `get_domain` (the processing state's previous version for an epoch before its fork). `sign_voluntary_exit` and `voluntary_exit_signing_root` take the current epoch for that. `ethlambda validator exit`: - The shard committee period is measured against max(current_epoch, exit.epoch), as the chain measures it, so an exit naming an old epoch is no longer refused for a validator that is eligible now. A pre-signed exit is still checked against the epoch it is signed for. - A future `--epoch` without `--presign` is refused before the prompt rather than by the node's 400 after it. - The wait for the chain is bounded at two epochs, ends on any status that carries an exit epoch (`active_slashed` and `withdrawal_*` included), and no longer turns a node error after the publish into a failed run: the exit went out, so the node is asked again next slot. - An aborted confirmation prompt, an empty stdin included, exits with a failing status. - The slots-to-seconds product no longer truncates through `as u32`, a JSON encoding failure is no longer reported as a malformed response, and the password is zeroized on drop like the secret it decrypts. The keymanager route's comment no longer claims the read lock is held across the beacon node round trip; a delete landing in between is the same 404. docs/cli.md follows all of the above and notes electra's pending-withdrawal refusal, which only the node can check. Tests: the domain for a pre-deneb epoch on a chain past deneb and for an epoch two forks back before it; the old-epoch, future-epoch and pre-signed eligibility cases; every terminal status of the wait, its bound, and an outage after the publish, with the mock answering as a chain that processed the exit instead of a racing flip; the keymanager route at `?epoch=0`; and an exit naming epoch 0 accepted by this node's Beacon API end to end. --- bin/ethlambda/src/validator.rs | 22 +- .../rpc/src/beacon/validator_client_tests.rs | 27 ++ crates/validator/src/beacon_node/mock.rs | 14 +- crates/validator/src/exit.rs | 313 ++++++++++++++---- .../validator/src/http_api/voluntary_exit.rs | 81 ++++- crates/validator/src/signing.rs | 141 ++++++-- docs/cli.md | 38 ++- 7 files changed, 503 insertions(+), 133 deletions(-) diff --git a/bin/ethlambda/src/validator.rs b/bin/ethlambda/src/validator.rs index b1dc539ba..7332eb48b 100644 --- a/bin/ethlambda/src/validator.rs +++ b/bin/ethlambda/src/validator.rs @@ -131,9 +131,9 @@ pub(crate) struct ExitOptions { #[arg(long, default_value = "http://localhost:5052")] pub(crate) beacon_node: String, - /// The exit's epoch. Defaults to the current one. A later epoch only makes - /// sense with --presign, since a node refuses an exit whose epoch has not - /// come. + /// The exit's epoch, before which the chain refuses it. Defaults to the + /// current one. A later epoch needs --presign, since a node refuses an + /// exit whose epoch has not come. #[arg(long)] pub(crate) epoch: Option, @@ -251,8 +251,13 @@ pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> { } /// Sign and publish one validator's voluntary exit, on its own runtime like -/// [`run`]. A run the operator aborted at the confirmation prompt is a clean -/// exit, not an error: nothing happened, and nothing needed to. +/// [`run`]. +/// +/// A run that did not get the confirmation phrase ends with a failing status. +/// Nothing went wrong, but nothing was published either, and that is what a +/// script reading the status asked about: one run without `--no-confirmation` +/// and with nobody at stdin reads an empty line, and must not look like an +/// exit that went out. #[tokio::main] pub(crate) async fn exit(options: ExitOptions) -> eyre::Result<()> { let outcome = exit::run(ExitConfig { @@ -269,9 +274,10 @@ pub(crate) async fn exit(options: ExitOptions) -> eyre::Result<()> { .await?; match outcome { Outcome::Presigned(signed) => exit::print_presigned(&signed)?, - Outcome::NotConfirmed => { - eprintln!("Nothing was published. The phrase is \"{CONFIRMATION_PHRASE}\"."); - } + Outcome::NotConfirmed => eyre::bail!( + "the voluntary exit was not published: the confirmation phrase is \ + \"{CONFIRMATION_PHRASE}\"" + ), Outcome::Published { .. } => {} } Ok(()) diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index b5364d2e3..a93e071bb 100644 --- a/crates/net/rpc/src/beacon/validator_client_tests.rs +++ b/crates/net/rpc/src/beacon/validator_client_tests.rs @@ -276,6 +276,33 @@ async fn the_validator_client_can_exit_a_validator_through_this_node() { matches!(&err, Error::BeaconNodeStatus { status: 400, body } if body.contains("voluntary exit")), "got {err:?}" ); + + // An exit naming a past epoch is one the chain accepts too: it measures + // the shard committee period against its own epoch and verifies under + // the fork it is at, neither of which the exit's epoch moves. + let pubkey = state.validator(1).unwrap().pubkey; + let keys = { + let mut store = ValidatorStore::new(); + store + .insert_secret("test", &secret_key_for(1).to_bytes()) + .expect("valid key"); + store + }; + let options = ExitOptions { + epoch: Some(0), + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + run_exit(&client, &keys, pubkey, &options, &mut no_input) + .await + .expect("the node accepts an exit naming epoch 0"); + let published = network.operations.lock().unwrap().clone(); + let [_, BeaconOperation::VoluntaryExit(exit)] = published.as_slice() else { + panic!("expected a second published exit, got {published:?}"); + }; + assert_eq!(exit.message.validator_index, 1); + assert_eq!(exit.message.epoch, 0); } /// One slot of an attester's work, in the order `ethlambda validator` does it. diff --git a/crates/validator/src/beacon_node/mock.rs b/crates/validator/src/beacon_node/mock.rs index 161b59365..46ea33866 100644 --- a/crates/validator/src/beacon_node/mock.rs +++ b/crates/validator/src/beacon_node/mock.rs @@ -133,9 +133,11 @@ pub struct MockBeaconNode { pub contribution_requests: Mutex>, pub published_contributions: Mutex>, pub sync_subscriptions: Mutex>, - /// What `validator` answers for any key. `None` is the node's 404. Behind - /// a mutex so a test can flip the status while the exit command waits. + /// What `validator` answers for any key. `None` is the node's 404. pub validator_details: Mutex>, + /// What `validator` answers once an exit has been submitted, standing in + /// for the chain processing it. `None` leaves the answer as it was. + pub details_after_exit: Mutex>, pub submitted_exits: Mutex>, } @@ -422,6 +424,11 @@ impl MockBeaconNode { *self.validator_details.lock().expect("lock") = details; } + pub fn with_details_after_exit(self, details: ValidatorDetails) -> Self { + *self.details_after_exit.lock().expect("lock") = Some(details); + self + } + pub fn submitted_exits(&self) -> Vec { self.submitted_exits.lock().expect("lock").clone() } @@ -974,6 +981,9 @@ impl BeaconNodeApi for MockBeaconNode { .lock() .expect("lock") .push(exit.clone()); + if let Some(details) = self.details_after_exit.lock().expect("lock").take() { + self.set_validator_details(Some(details)); + } Ok(()) } } diff --git a/crates/validator/src/exit.rs b/crates/validator/src/exit.rs index 81c6d341a..81296d6b8 100644 --- a/crates/validator/src/exit.rs +++ b/crates/validator/src/exit.rs @@ -9,6 +9,11 @@ //! past `SHARD_COMMITTEE_PERIOD` since activation), signs, and then, unless //! told not to, asks for a confirmation phrase before publishing anything. //! +//! One of the chain's rules cannot be checked from here: from electra on, a +//! validator with a partial withdrawal still pending is refused +//! (`get_pending_balance_to_withdraw(state, index) == 0`). That refusal comes +//! back as the node's 400, with the node's reason. +//! //! `--presign` stops after signing and prints the `SignedVoluntaryExit` as JSON //! instead, for an operator who wants to hold a signed exit for later. EIP-7044 //! made that useful: from deneb on, exits sign under a fixed fork version, so @@ -20,10 +25,12 @@ use std::io::BufRead; use std::path::{Path, PathBuf}; +use ethlambda_types::beacon::constants::FAR_FUTURE_EPOCH; use ethlambda_types::beacon::containers::shared::{SignedVoluntaryExit, VoluntaryExit}; use ethlambda_types::beacon::preset::SLOTS_PER_EPOCH; use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch}; use tracing::info; +use zeroize::Zeroizing; use crate::beacon_node::dto::encode_hex; use crate::beacon_node::http::HttpBeaconNode; @@ -38,6 +45,12 @@ use crate::slot_clock::SlotClock; /// for, so an operator moving between the two has one thing to remember. pub const CONFIRMATION_PHRASE: &str = "Exit my validator"; +/// How many epochs the command waits for the chain to process an exit it +/// published. A node that accepted one has it in a block within a slot or +/// two, so this rides out a run of missed slots and still lets a run end: an +/// exit the chain has not processed by then is not one more slot away. +const WAIT_EPOCHS: u64 = 2; + /// Everything the command is configured with. #[derive(Debug, Clone)] pub struct ExitConfig { @@ -53,8 +66,9 @@ pub struct ExitConfig { /// The flags that shape the run, apart from where the key and the node are. #[derive(Debug, Clone, Default)] pub struct ExitOptions { - /// The exit's epoch. `None` means the current one. A later epoch only makes - /// sense with `presign`: a node refuses an exit whose epoch has not come. + /// The exit's epoch, before which the chain refuses it. `None` means the + /// current one. A later epoch needs `presign`: a node refuses an exit + /// whose epoch has not come, so the command refuses to publish one. pub epoch: Option, /// Sign and print the exit as JSON without publishing it. pub presign: bool, @@ -73,7 +87,8 @@ pub enum Outcome { /// The operator did not type the phrase, so nothing was published. NotConfirmed, /// The node accepted the exit for gossip. `accepted` is what the chain - /// then said about the validator, or `None` under `no_wait`. + /// then said about the validator, or `None` under `no_wait` and when the + /// chain had not processed the exit by the end of the wait. Published { accepted: Option }, } @@ -108,6 +123,8 @@ pub fn load_keystore( path: password_path.display().to_string(), source, })?; + // Scrubbed on drop, like the secret `decrypt` yields from it. + let password = Zeroizing::new(password); let secret = keystore .decrypt(password.trim_end_matches(['\n', '\r'])) .map_err(|err| Error::Keystore { @@ -144,13 +161,22 @@ pub async fn run_with( let current_epoch = current_epoch(&clock)?; let epoch = options.epoch.unwrap_or(current_epoch); - let details = eligible_for_exit(node, &pubkey, epoch, &signing).await?; + // The chain refuses an exit before its own epoch, so publishing one is a + // trip through the prompt to a node's 400. Holding it until then is what + // `presign` is for. + if !options.presign && epoch > current_epoch { + return Err(Error::ExitRefused(format!( + "the exit's epoch {epoch} has not come (the current epoch is {current_epoch}), so a \ + beacon node would refuse it; pass --presign to sign it now and publish it then" + ))); + } + let details = eligible_for_exit(node, &pubkey, epoch, current_epoch, &signing).await?; let exit = VoluntaryExit { epoch, validator_index: details.index, }; - let signature = signing.sign_voluntary_exit(store, &pubkey, &exit)?; + let signature = signing.sign_voluntary_exit(store, &pubkey, &exit, current_epoch)?; let signed = SignedVoluntaryExit { message: exit, signature, @@ -207,10 +233,8 @@ pub async fn run_with( return Ok(Outcome::Published { accepted: None }); } - let accepted = wait_for_acceptance(node, &pubkey, &clock).await?; - Ok(Outcome::Published { - accepted: Some(accepted), - }) + let accepted = wait_for_acceptance(node, &pubkey, &clock).await; + Ok(Outcome::Published { accepted }) } fn current_epoch(clock: &SlotClock) -> Result { @@ -223,13 +247,21 @@ fn current_epoch(clock: &SlotClock) -> Result { } /// The validator's registry entry, if the chain would accept an exit for it -/// at `epoch`: active, not already exiting, and past its shard committee +/// naming `epoch`: active, not already exiting, and past its shard committee /// period. The same gate `process_voluntary_exit` applies, checked here so /// the refusal names the reason instead of arriving as a node's 400. +/// +/// The chain measures the shard committee period against the epoch it +/// processes the exit in, not the one the exit names, and the earliest that +/// can be is `max(current_epoch, epoch)`: now, for an exit whose epoch has +/// come, and its own epoch for a pre-signed one that has not. So an exit +/// naming an old epoch is as eligible as one naming the current epoch, and a +/// pre-signed one is checked against the epoch it is signed for. async fn eligible_for_exit( node: &B, pubkey: &BlsPubkey, epoch: Epoch, + current_epoch: Epoch, signing: &SigningContext, ) -> Result { let Some(details) = node.validator(pubkey).await? else { @@ -255,7 +287,7 @@ async fn eligible_for_exit( encode_hex(&pubkey.0) )) })?; - if epoch < eligible_epoch { + if current_epoch.max(epoch) < eligible_epoch { return Err(Error::ExitRefused(format!( "validator {} is not eligible for a voluntary exit before epoch {eligible_epoch} \ (activated at epoch {}, and SHARD_COMMITTEE_PERIOD is {} epochs)", @@ -268,27 +300,49 @@ async fn eligible_for_exit( } /// Poll the validator's status once a slot until the chain has processed the -/// exit, reporting what it said. +/// exit, reporting what it said, for up to [`WAIT_EPOCHS`]. `None` when it had +/// not by then. +/// +/// Nothing here fails the run. The exit is already published, so a node that +/// cannot answer one poll is asked again at the next, and a wait that runs out +/// says the exit went out rather than reporting an error for it. +/// +/// "Processed" is read off the registry entry, not the status string: the +/// chain gives a validator an exit epoch when it initiates its exit and never +/// takes it back. That ends the wait on every status that follows +/// (`active_exiting`, the `exited_*` and `withdrawal_*` ones a late poll or a +/// short withdrawability delay lands on) and on `active_slashed`, a validator +/// slashed while this waited, which is on its way out too. async fn wait_for_acceptance( node: &B, pubkey: &BlsPubkey, clock: &SlotClock, -) -> Result { +) -> Option { let slot_duration = clock.slot_duration(); - loop { + for _ in 0..WAIT_EPOCHS * SLOTS_PER_EPOCH { tokio::time::sleep(slot_duration).await; - let Some(details) = node.validator(pubkey).await? else { - return Err(Error::InconsistentResponse(format!( - "validator {} vanished from the beacon state while waiting for its exit", - encode_hex(&pubkey.0) - ))); + let details = match node.validator(pubkey).await { + Ok(Some(details)) if details.exit_epoch != FAR_FUTURE_EPOCH => details, + Ok(_) => { + eprintln!("Waiting for the chain to accept the voluntary exit..."); + continue; + } + Err(err) => { + eprintln!("Could not read the validator's status, asking again next slot: {err}"); + continue; + } }; match details.status.as_str() { "active_exiting" => { - let current_epoch = current_epoch(clock)?; - let epochs_to_go = details.exit_epoch.saturating_sub(current_epoch); + // Before genesis there is no exit to wait for, so the clock + // has an epoch here; zero only keeps this from being a panic. + let current_epoch = clock.now().map_or(0, |slot| clock.epoch_of(slot)); + let slots_to_go = details + .exit_epoch + .saturating_sub(current_epoch) + .saturating_mul(SLOTS_PER_EPOCH); let seconds_to_go = slot_duration - .saturating_mul(epochs_to_go.saturating_mul(SLOTS_PER_EPOCH) as u32) + .saturating_mul(u32::try_from(slots_to_go).unwrap_or(u32::MAX)) .as_secs(); eprintln!( "The chain has accepted the voluntary exit, but it is not finalized yet. \ @@ -303,24 +357,30 @@ async fn wait_for_acceptance( "Keep the validator running until its exit epoch, in roughly {seconds_to_go} \ seconds." ); - return Ok(details); - } - "exited_unslashed" | "exited_slashed" => { - eprintln!("The validator exited at epoch {}.", details.exit_epoch); - return Ok(details); - } - _ => { - eprintln!("Waiting for the chain to accept the voluntary exit..."); } + "active_slashed" => eprintln!( + "The validator was slashed, and leaves the validator set at epoch {}.", + details.exit_epoch + ), + _ => eprintln!("The validator exited at epoch {}.", details.exit_epoch), } + return Some(details); } + eprintln!( + "The voluntary exit was published, but the chain has not processed it after {WAIT_EPOCHS} \ + epochs. Check the validator's status later; if it is still active_ongoing, run the \ + command again." + ); + None } /// Print a pre-signed exit the way the command does: as pretty JSON on stdout /// and nothing else there, so it pipes into a file or another tool. pub fn print_presigned(signed: &SignedVoluntaryExit) -> Result<()> { - let json = serde_json::to_string_pretty(signed) - .map_err(|err| Error::Decode(format!("could not encode the signed exit: {err}")))?; + let json = serde_json::to_string_pretty(signed).map_err(|err| Error::Io { + path: "stdout".to_string(), + source: err.into(), + })?; println!("{json}"); Ok(()) } @@ -328,9 +388,9 @@ pub fn print_presigned(signed: &SignedVoluntaryExit) -> Result<()> { #[cfg(test)] mod tests { use std::io::Cursor; + use std::time::Duration; use ethlambda_types::beacon::config::Config; - use ethlambda_types::beacon::constants::FAR_FUTURE_EPOCH; use ethlambda_types::beacon::primitives::Root; use super::*; @@ -440,24 +500,81 @@ mod tests { assert!(node.submitted_exits().is_empty()); } + fn now() -> Epoch { + let clock = SlotClock::from_config(genesis().genesis_time, &Config::mainnet()); + clock.epoch_of(clock.now().expect("after genesis")) + } + /// The chain refuses an exit before `activation_epoch + /// SHARD_COMMITTEE_PERIOD`; so does this, naming the epoch it becomes /// possible at, which a node's 400 would not. #[tokio::test] async fn a_validator_inside_its_shard_committee_period_is_refused() { - let activation = 5; + let activation = now() - 1; + let eligible = activation + Config::mainnet().shard_committee_period; let node = node_with(active(activation)); - let far_future = ExitOptions { - epoch: Some(activation + 1), - presign: true, + let no_prompt = ExitOptions { + no_confirmation: true, + no_wait: true, ..ExitOptions::default() }; - let err = run(&node, far_future, "").await.expect_err("must refuse"); - let eligible = activation + Config::mainnet().shard_committee_period; + let err = run(&node, no_prompt, "").await.expect_err("must refuse"); assert!( matches!(&err, Error::ExitRefused(reason) if reason.contains(&format!("before epoch {eligible}"))), "got {err:?}" ); + assert!(node.submitted_exits().is_empty()); + + // A pre-signed exit is checked against the epoch it is signed for: + // one short of eligibility is refused, the eligible epoch is not. + for (epoch, signs) in [(eligible - 1, false), (eligible, true)] { + let presign = ExitOptions { + epoch: Some(epoch), + presign: true, + ..ExitOptions::default() + }; + let outcome = run(&node, presign, "").await; + assert_eq!(outcome.is_ok(), signs, "epoch {epoch}: {outcome:?}"); + } + } + + /// The chain measures the shard committee period against the epoch it + /// processes the exit in, not the one the exit names, so an exit naming + /// an epoch from before the validator was eligible, epoch 0 included, is + /// one it accepts from a validator that is eligible now. + #[tokio::test] + async fn an_exit_naming_a_past_epoch_is_published_for_an_eligible_validator() { + let node = node_with(active(0)); + for epoch in [0, Config::mainnet().shard_committee_period - 1] { + let options = ExitOptions { + epoch: Some(epoch), + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + run(&node, options, "").await.expect("publishes"); + let published = node.submitted_exits(); + assert_eq!(published.last().expect("one more").message.epoch, epoch); + } + } + + /// A node refuses an exit whose epoch has not come, so the command does + /// not walk the operator through the prompt to that 400. + #[tokio::test] + async fn an_exit_for_a_future_epoch_is_not_published_without_presign() { + let node = node_with(active(0)); + let options = ExitOptions { + epoch: Some(now() + 1), + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + let err = run(&node, options, "").await.expect_err("must refuse"); + assert!( + matches!(&err, Error::ExitRefused(reason) if reason.contains("--presign")), + "got {err:?}" + ); + assert!(node.submitted_exits().is_empty()); } #[tokio::test] @@ -488,7 +605,7 @@ mod tests { config: Config::mainnet(), genesis_validators_root: genesis().genesis_validators_root, }; - let root = signing.voluntary_exit_signing_root(&signed.message); + let root = signing.voluntary_exit_signing_root(&signed.message, now()); let pk = PublicKey::from_bytes(&pubkey.0).expect("valid pubkey"); let sig = Signature::from_bytes(&signed.signature.0).expect("valid signature"); assert_eq!( @@ -553,42 +670,110 @@ mod tests { ..ExitOptions::default() }; run(&node, options, "").await.expect("publishes"); - let clock = SlotClock::from_config(genesis().genesis_time, &Config::mainnet()); - let now = clock.epoch_of(clock.now().expect("after genesis")); - assert_eq!(node.submitted_exits()[0].message.epoch, now); + assert_eq!(node.submitted_exits()[0].message.epoch, now()); } - /// With `start_paused`, the once-a-slot sleep completes at once, so the - /// wait loop is exercised without waiting a slot. - #[tokio::test(start_paused = true)] - async fn the_wait_ends_once_the_chain_reports_the_validator_exiting() { - let node = node_with(active(0)); - let (store, pubkey) = store_with_key(); - let exiting = ValidatorDetails { - status: "active_exiting".to_string(), + fn exiting(status: &str) -> ValidatorDetails { + ValidatorDetails { + status: status.to_string(), exit_epoch: 1_000_000, withdrawable_epoch: 1_000_256, ..active(0) - }; - let options = ExitOptions { + } + } + + fn waits() -> ExitOptions { + ExitOptions { no_confirmation: true, ..ExitOptions::default() - }; - // The exit is checked against the active entry; the poll then sees - // the exiting one. - let mut input = phrase(); + } + } + + /// With `start_paused`, the once-a-slot sleep completes at once, so the + /// wait loop is exercised without waiting a slot. The exit is checked + /// against the active entry; the node answers with the other one from the + /// moment it has the exit, as a chain that processed it would. + /// + /// Every status that follows an initiated exit ends the wait, not only + /// `active_exiting`: a late poll lands on a later one, and a validator + /// slashed meanwhile is leaving too. + #[tokio::test(start_paused = true)] + async fn the_wait_ends_once_the_chain_has_given_the_validator_an_exit_epoch() { + for status in [ + "active_exiting", + "active_slashed", + "exited_unslashed", + "exited_slashed", + "withdrawal_possible", + "withdrawal_done", + ] { + let node = node_with(active(0)).with_details_after_exit(exiting(status)); + let outcome = run(&node, waits(), "").await.expect("published"); + assert_eq!( + outcome, + Outcome::Published { + accepted: Some(exiting(status)) + }, + "{status}" + ); + } + } + + /// An exit the chain never processes does not hold the command forever, + /// and is not an error either: it was published. + #[tokio::test(start_paused = true)] + async fn the_wait_gives_up_on_an_exit_the_chain_does_not_process() { + let node = node_with(active(0)); + let started = tokio::time::Instant::now(); + let outcome = run(&node, waits(), "").await.expect("published"); + assert_eq!(outcome, Outcome::Published { accepted: None }); + assert_eq!(node.submitted_exits().len(), 1); + + let slot = Duration::from_millis(Config::mainnet().slot_duration_ms); + let waited = slot.saturating_mul((WAIT_EPOCHS * SLOTS_PER_EPOCH) as u32); + assert_eq!(started.elapsed(), waited); + } + + /// Once the exit is out, a node that cannot answer is not a failed run: + /// reporting an error for an exit that was published would say the + /// opposite of what happened. The node is asked again, and the run ends + /// with what it says once it answers. + #[tokio::test(start_paused = true)] + async fn a_node_that_cannot_answer_after_the_publish_is_asked_again() { + let node = node_with(active(0)).with_details_after_exit(exiting("active_exiting")); + let (store, pubkey) = store_with_key(); + let options = waits(); + let mut input = Cursor::new(String::new()); + let slot = Duration::from_millis(Config::mainnet().slot_duration_ms); + let started = tokio::time::Instant::now(); + let run = run_with(&node, &store, pubkey, &options, &mut input); - tokio::pin!(run); - let flip = async { - tokio::task::yield_now().await; - node.set_validator_details(Some(exiting.clone())); + // The registry lookup fails from the moment the exit is in, for three + // slots. Time is paused, so it only moves once both futures sleep, + // and the first poll cannot come before the failure is in place. + let outage = async { + while node.submitted_exits().is_empty() { + tokio::task::yield_now().await; + } + let failure = "connection refused".to_string(); + node.failures + .lock() + .expect("lock") + .insert("validator", failure); + tokio::time::sleep(slot * 3).await; + node.stop_failing("validator"); }; - let (outcome, ()) = tokio::join!(run, flip); + let (outcome, ()) = tokio::join!(run, outage); + assert_eq!( - outcome.expect("published and accepted"), + outcome.expect("published"), Outcome::Published { - accepted: Some(exiting) + accepted: Some(exiting("active_exiting")) } ); + assert!( + started.elapsed() >= slot * 3, + "the polls during the outage must have failed, not been skipped" + ); } } diff --git a/crates/validator/src/http_api/voluntary_exit.rs b/crates/validator/src/http_api/voluntary_exit.rs index 597a27ea8..1aa624270 100644 --- a/crates/validator/src/http_api/voluntary_exit.rs +++ b/crates/validator/src/http_api/voluntary_exit.rs @@ -7,16 +7,21 @@ //! an operator pre-sign an exit for a later epoch and hold on to it. //! //! The exit epoch is the `epoch` query parameter, or the current epoch by the -//! client's own clock when none is given. The validator index is asked of the -//! beacon node on every call rather than cached: a key whose deposit has not -//! been processed has no index yet, and a 404 saying so is the honest answer -//! until it does. +//! client's own clock when none is given. Whatever epoch it names, the exit is +//! signed for the fork that will process it (see +//! [`crate::signing::SigningContext::voluntary_exit_signing_root`]), so +//! `?epoch=0` on a chain past deneb is as valid as the current epoch. +//! +//! The validator index is asked of the beacon node on every call rather than +//! cached: a key whose deposit has not been processed has no index yet, and a +//! 404 saying so is the honest answer until it does. //! //! Every route answers 404 for a key this client does not hold, as the //! settings routes do. use axum::Json; use axum::extract::{Path, Query, State}; +use ethlambda_types::beacon::constants::GENESIS_EPOCH; use ethlambda_types::beacon::containers::shared::{SignedVoluntaryExit, VoluntaryExit}; use serde::Deserialize; use tracing::info; @@ -39,15 +44,17 @@ pub async fn create_signed_voluntary_exit( Query(query): Query, ) -> Result>, ApiError> { let pubkey = parse_path_key(&pubkey)?; + let current_epoch = context.clock.now().map(|slot| context.clock.epoch_of(slot)); let epoch = match &query.epoch { Some(text) => parse_uint64("epoch", text)?, - None => { - let slot = context.clock.now().ok_or_else(|| { - ApiError::Internal("the chain's genesis is in the future".to_string()) - })?; - context.clock.epoch_of(slot) - } + None => current_epoch.ok_or_else(|| { + ApiError::Internal("the chain's genesis is in the future".to_string()) + })?, }; + // The domain follows the fork that will process the exit, which is the + // chain's present one for an exit naming a past epoch. Before genesis + // the chain's first state is the earliest that can. + let current_epoch = current_epoch.unwrap_or(GENESIS_EPOCH); // The key is checked before the beacon node is asked, so a key this // client does not hold costs no round trip and is answered the same way @@ -73,13 +80,13 @@ pub async fn create_signed_voluntary_exit( epoch, validator_index: entry.index, }; - // Signed under the store's read lock, like the settings routes change - // their setting under it: a keystore delete, which takes the write lock, - // cannot land between the check above and the signature. + // The read lock is not held across the round trip to the beacon node, so + // a keystore delete can land between the check above and here. Signing + // then finds no key, which is the same 404 the check gives. let store = context.store.read().await; let signature = context .signing - .sign_voluntary_exit(&store, &pubkey, &exit) + .sign_voluntary_exit(&store, &pubkey, &exit, current_epoch) .map_err(|err| match err { crate::Error::UnknownValidator(_) => ApiError::NotFound(format!( "no validator with pubkey {} is held by this client", @@ -146,6 +153,11 @@ mod tests { /// to `INDEX` when `resolves` is set, and a clock a few epochs past /// genesis. fn harness(resolves: bool) -> Harness { + harness_at(10, resolves) + } + + /// [`harness`] with the clock `epochs` epochs past genesis. + fn harness_at(epochs: u64, resolves: bool) -> Harness { let mut store = ValidatorStore::new(); let pubkey = store.insert_secret("test", &secret()).expect("inserts"); let mut node = MockBeaconNode::new(); @@ -161,7 +173,7 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .expect("after the epoch") .as_secs() - - 10 * 32 * 12; + - epochs * 32 * 12; let clock = SlotClock::from_config(genesis_time, &config); let signing = Arc::new(SigningContext { config, @@ -234,13 +246,17 @@ mod tests { let signed: SignedVoluntaryExit = serde_json::from_value(body["data"].clone()).expect("a SignedVoluntaryExit"); - let now = harness.clock.now().expect("after genesis"); - assert_eq!(signed.message.epoch, harness.clock.epoch_of(now)); + let now = harness + .clock + .epoch_of(harness.clock.now().expect("after genesis")); + assert_eq!(signed.message.epoch, now); assert_eq!(signed.message.validator_index, INDEX); assert!(verify( &harness.pubkey, &signed.signature, - harness.signing.voluntary_exit_signing_root(&signed.message) + harness + .signing + .voluntary_exit_signing_root(&signed.message, now) )); // The wire form quotes its integers, as every Beacon API container does. assert!(body["data"]["message"]["epoch"].is_string(), "got {body}"); @@ -259,6 +275,35 @@ mod tests { assert_eq!(body["data"]["message"]["epoch"], "999999"); } + /// An exit naming a past epoch is signed for the chain as it is now, not + /// as it was then: here a chain past deneb, whose nodes verify every exit + /// under the capella version, one naming epoch 0 included. + #[tokio::test] + async fn an_exit_naming_a_past_epoch_is_signed_for_the_chains_present_fork() { + use ethlambda_types::beacon::constants::DOMAIN_VOLUNTARY_EXIT; + use ethlambda_types::beacon::primitives::HashTreeRoot as _; + use ethlambda_types::beacon::signing::{compute_domain, compute_signing_root}; + + let harness = harness_at(Config::mainnet().electra_fork_epoch + 10, true); + let request = post(&uri(&harness.pubkey, "?epoch=0"), Some(TOKEN)); + let (status, body) = send(&harness.app, request).await; + assert_eq!(status, StatusCode::OK, "got {body}"); + + let signed: SignedVoluntaryExit = + serde_json::from_value(body["data"].clone()).expect("a SignedVoluntaryExit"); + assert_eq!(signed.message.epoch, 0); + let capella = compute_domain( + DOMAIN_VOLUNTARY_EXIT, + harness.signing.config.capella_fork_version, + harness.signing.genesis_validators_root, + ); + assert!(verify( + &harness.pubkey, + &signed.signature, + compute_signing_root(signed.message.hash_tree_root(), capella) + )); + } + #[tokio::test] async fn a_malformed_epoch_is_a_bad_request() { let harness = harness(true); diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs index 389fe4bac..2c673521c 100644 --- a/crates/validator/src/signing.rs +++ b/crates/validator/src/signing.rs @@ -197,23 +197,33 @@ impl SigningContext { /// The root a voluntary exit's signature is computed over. /// - /// The domain does not follow the fork schedule the way every other - /// signature here does. EIP-7044, part of deneb, pinned exits to - /// `CAPELLA_FORK_VERSION` for good: a `process_voluntary_exit` on any - /// deneb-or-later state verifies under the capella version whatever the - /// exit's own epoch names, so an exit signed now stays valid across every - /// fork to come. The point was pre-signed exits, which a staking service - /// hands its customer so they can leave without the service's cooperation; - /// a domain that moved with the schedule made those expire at each fork. + /// Every other signature here follows the fork at its message's own epoch. + /// An exit follows the fork of the state that *processes* it, because that + /// is the state a node verifies it against, and the two part ways as soon + /// as an exit names an epoch from an earlier fork: /// - /// Before deneb the rule was the ordinary one, the fork version at the - /// exit's epoch. The switch is keyed on the fork at that epoch, as - /// Lighthouse keys it, which gives the same answer as the processing - /// state's fork for every exit a live network can still process. - pub fn voluntary_exit_signing_root(&self, exit: &VoluntaryExit) -> Root { - let fork = self.config.fork_at_epoch(exit.epoch); + /// - A deneb-or-later state verifies under `CAPELLA_FORK_VERSION` whatever + /// epoch the exit names (EIP-7044), so an exit signed now stays valid + /// across every fork to come. The point was pre-signed exits, which a + /// staking service hands its customer so they can leave without the + /// service's cooperation; a domain that moved with the schedule made + /// those expire at each fork. + /// - An earlier state verifies under `get_domain(state, + /// DOMAIN_VOLUNTARY_EXIT, exit.epoch)`: its previous fork version for an + /// exit naming an epoch before its own fork, its current one otherwise. + /// + /// No state can process the exit before `max(current_epoch, exit.epoch)`: + /// the chain is already at the one, and an exit is refused before the + /// other. So the fork at that epoch is the one to sign for. Keying on + /// `exit.epoch` alone, as Lighthouse does, signs an exit naming a + /// pre-deneb epoch under that epoch's version, which no node on a chain + /// past deneb accepts. + pub fn voluntary_exit_signing_root(&self, exit: &VoluntaryExit, current_epoch: Epoch) -> Root { + let fork = self.config.fork_at_epoch(current_epoch.max(exit.epoch)); let fork_version = if fork >= ForkName::Deneb { self.config.capella_fork_version + } else if exit.epoch < self.config.fork_epoch(fork) { + self.config.fork_version(fork.previous().unwrap_or(fork)) } else { self.config.fork_version(fork) }; @@ -402,13 +412,18 @@ impl SigningContext { /// the caller's: the command line asks for a confirmation phrase, and the /// keymanager route is bearer-authenticated and only ever returns the /// signed message to its caller, which is what then decides to publish. + /// + /// `current_epoch` is the chain's, by the caller's clock; see + /// [`Self::voluntary_exit_signing_root`] for why the domain needs it. pub fn sign_voluntary_exit( &self, store: &ValidatorStore, pubkey: &BlsPubkey, exit: &VoluntaryExit, + current_epoch: Epoch, ) -> Result { - self.sign_root(store, pubkey, self.voluntary_exit_signing_root(exit)) + let root = self.voluntary_exit_signing_root(exit, current_epoch); + self.sign_root(store, pubkey, root) } /// Sign the block whose root is `block_root`, proposed for `slot`, on @@ -810,6 +825,16 @@ mod tests { )); } + /// The root an exit signs to under `version`, for the tests below. + fn exit_root_under(context: &SigningContext, exit: &VoluntaryExit, version: [u8; 4]) -> Root { + let domain = compute_domain( + DOMAIN_VOLUNTARY_EXIT, + version, + context.genesis_validators_root, + ); + compute_signing_root(exit.hash_tree_root(), domain) + } + #[test] fn a_voluntary_exit_verifies_under_its_own_root() { let (store, pubkey) = store_with_key(); @@ -820,12 +845,12 @@ mod tests { }; let signature = context - .sign_voluntary_exit(&store, &pubkey, &exit) + .sign_voluntary_exit(&store, &pubkey, &exit, exit.epoch) .expect("signs"); assert!(verify( &pubkey, &signature, - context.voluntary_exit_signing_root(&exit) + context.voluntary_exit_signing_root(&exit, exit.epoch) )); } @@ -842,15 +867,8 @@ mod tests { epoch, validator_index: 1, }; - let expected = compute_signing_root( - exit.hash_tree_root(), - compute_domain( - DOMAIN_VOLUNTARY_EXIT, - context.config.capella_fork_version, - context.genesis_validators_root, - ), - ); - assert_eq!(context.voluntary_exit_signing_root(&exit), expected); + let expected = exit_root_under(&context, &exit, context.config.capella_fork_version); + assert_eq!(context.voluntary_exit_signing_root(&exit, epoch), expected); assert_ne!( expected, compute_signing_root( @@ -862,8 +880,45 @@ mod tests { } } + /// The rule is the processing state's, not the exit's epoch's: a chain + /// past deneb verifies every exit under the capella version, one naming + /// epoch 0 included. Signing that one under the genesis version, which is + /// what keying on `exit.epoch` did, produced an exit no node accepts. + #[test] + fn an_exit_naming_a_pre_deneb_epoch_signs_under_capella_on_a_chain_past_deneb() { + let context = context(); + let config = &context.config; + let now = config.electra_fork_epoch + 1_000; + for epoch in [ + 0, + config.altair_fork_epoch, + config.capella_fork_epoch + 1, + config.deneb_fork_epoch - 1, + ] { + let exit = VoluntaryExit { + epoch, + validator_index: 1, + }; + assert_eq!( + context.voluntary_exit_signing_root(&exit, now), + exit_root_under(&context, &exit, config.capella_fork_version), + "exit epoch {epoch}" + ); + } + // A pre-signed exit for a deneb epoch, signed while the chain is still + // before deneb, is processed by a deneb state too. + let exit = VoluntaryExit { + epoch: config.deneb_fork_epoch, + validator_index: 1, + }; + assert_eq!( + context.voluntary_exit_signing_root(&exit, config.altair_fork_epoch), + exit_root_under(&context, &exit, config.capella_fork_version) + ); + } + /// Before deneb the ordinary rule held: the version of the fork at the - /// exit's epoch. + /// exit's epoch, for an exit processed in that fork. #[test] fn an_exit_before_deneb_signs_under_its_epochs_fork_version() { let context = context(); @@ -872,7 +927,7 @@ mod tests { validator_index: 1, }; assert_eq!( - context.voluntary_exit_signing_root(&exit), + context.voluntary_exit_signing_root(&exit, exit.epoch), compute_signing_root( exit.hash_tree_root(), context.domain(DOMAIN_VOLUNTARY_EXIT, exit.epoch) @@ -880,6 +935,36 @@ mod tests { ); } + /// A pre-deneb state verifies through `get_domain`, which knows two + /// versions: its own, and its previous one for any epoch before its fork. + /// One fork back that is the exit's own version; two forks back it is not, + /// and the chain's answer is the one to sign under. + #[test] + fn an_exit_before_deneb_signs_under_what_the_processing_state_verifies() { + let context = context(); + let config = &context.config; + let in_bellatrix = config.bellatrix_fork_epoch + 5; + + let one_fork_back = VoluntaryExit { + epoch: config.altair_fork_epoch + 1, + validator_index: 1, + }; + assert_eq!( + context.voluntary_exit_signing_root(&one_fork_back, in_bellatrix), + exit_root_under(&context, &one_fork_back, config.altair_fork_version) + ); + + let two_forks_back = VoluntaryExit { + epoch: config.altair_fork_epoch - 1, + validator_index: 1, + }; + assert_eq!( + context.voluntary_exit_signing_root(&two_forks_back, in_bellatrix), + exit_root_under(&context, &two_forks_back, config.altair_fork_version), + "a bellatrix state's previous version is altair's, not genesis's" + ); + } + #[test] fn signing_a_randao_reveal_for_an_unknown_validator_is_an_error() { let store = ValidatorStore::new(); diff --git a/docs/cli.md b/docs/cli.md index a4dd0a157..827ea01fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -314,7 +314,7 @@ ethlambda validator exit \ | `--keystore` | required | The validator's EIP-2335 voting keystore | | `--password-file` | required | The file holding that keystore's password. A trailing newline is ignored | | `--beacon-node` | `http://localhost:5052` | Base URL of the beacon node's HTTP API. One node, not a list: this is a one-shot command, and an operator whose node is down picks another | -| `--epoch` | the current epoch | The exit's epoch. A later one only makes sense with `--presign`, since a node refuses an exit whose epoch has not come | +| `--epoch` | the current epoch | The exit's epoch, before which the chain refuses it. An earlier one is as good as the current one. A later one needs `--presign`: a node refuses an exit whose epoch has not come, so the command refuses to publish one | | `--presign` | off | Sign the exit and print it as JSON on stdout without publishing it, for an operator who wants to hold a signed exit for later. Logs go to stderr, so the JSON pipes cleanly into a file | | `--no-confirmation` | off | Publish without asking for the confirmation phrase. For scripts; use with care | | `--no-wait` | off | Return as soon as the beacon node has accepted the exit, without waiting for the chain to process it | @@ -325,20 +325,32 @@ can never validate again. So before publishing anything the command refuses a beacon node that is still syncing, refuses a validator the chain would refuse (one that is not `active_ongoing`, or not yet `SHARD_COMMITTEE_PERIOD` epochs past its activation, naming the epoch it becomes eligible at), and then prints -a warning and asks for the phrase `Exit my validator` on stdin. Anything else -aborts without publishing. +a warning and asks for the phrase `Exit my validator` on stdin. Anything else, +an empty stdin included, aborts without publishing and with a failing exit +status, so a script cannot take an exit that never went out for one that did. + +The shard committee period is measured the way the chain measures it: against +the epoch the exit is processed in, which is now for an exit being published +and the exit's own epoch for a `--presign` one naming a later epoch. One rule +of the chain's cannot be checked up front: from electra on it refuses a +validator with a partial withdrawal still pending. That refusal comes back as +the beacon node's `400`, with its reason. After publishing, unless `--no-wait` is given, it polls the validator's status -once a slot. When the chain reports `active_exiting` it prints the current, -exit and withdrawable epochs and how long until the exit epoch, and returns; -**keep the validator running until that epoch**, since it still has duties -until then. A validator already `exited_unslashed` or `exited_slashed` is -reported as such. - -From deneb on, exits are signed under `CAPELLA_FORK_VERSION` whatever their -epoch (EIP-7044), so a pre-signed exit stays valid across every fork to come. -The command signs that way, and the keymanager API's `voluntary_exit` route -below does too. +once a slot, for up to two epochs. When the chain reports `active_exiting` it +prints the current, exit and withdrawable epochs and how long until the exit +epoch, and returns; **keep the validator running until that epoch**, since it +still has duties until then. Any later status (`exited_*`, `withdrawal_*`) and +`active_slashed` end the wait too, reported as such. If the chain has not +processed the exit when the wait runs out, the command says so and returns +successfully, since the exit was published; a beacon node that cannot answer a +poll is asked again at the next slot rather than failing the run. + +Exits are signed for the fork that processes them, not the one their epoch +falls in. From deneb on that is `CAPELLA_FORK_VERSION` whatever epoch the exit +names (EIP-7044), an epoch from before deneb included, so a pre-signed exit +stays valid across every fork to come. The command signs that way, and the +keymanager API's `voluntary_exit` route below does too. ### What it does today