diff --git a/bin/ethlambda/src/command.rs b/bin/ethlambda/src/command.rs index d5cfe4d54..9d1325c3a 100644 --- a/bin/ethlambda/src/command.rs +++ b/bin/ethlambda/src/command.rs @@ -85,6 +85,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), /// Import or export the validator client's EIP-3076 slashing-protection /// history, offline. @@ -203,8 +210,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 db3f62368..94be5c0d2 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -106,10 +106,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) + } + }, // Offline file work: no runtime, and stdout left alone. Command::SlashingProtection(options) => { init_info_stderr_logging()?; diff --git a/bin/ethlambda/src/validator.rs b/bin/ethlambda/src/validator.rs index 968244963..7332eb48b 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")] @@ -93,7 +107,66 @@ pub(crate) struct ValidatorOptions { pub(crate) init_slashing_protection: 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, 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, + + /// 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. /// @@ -157,31 +230,69 @@ 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 init_slashing_protection = options.init_slashing_protection; + 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)), - init_slashing_protection: options.init_slashing_protection, + validators_dir, + secrets_dir, + metrics, + keymanager, + init_slashing_protection, }) .await .map_err(Into::into) } +/// Sign and publish one validator's voluntary exit, on its own runtime like +/// [`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 { + 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 => eyre::bail!( + "the voluntary exit was not published: the confirmation 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 b62b61cdc..a93e071bb 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,114 @@ 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:?}" + ); + + // 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. /// /// 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 f05107ab0..ab6aa4c00 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..46ea33866 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,12 @@ 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. + 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>, } impl MockBeaconNode { @@ -408,6 +415,24 @@ 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 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() + } + pub fn with_publish_outcome(mut self, outcome: Published) -> Self { self.publish_outcome = Some(outcome); self @@ -944,4 +969,21 @@ 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()); + 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/beacon_node/mod.rs b/crates/validator/src/beacon_node/mod.rs index 49c1c54dd..9cea54d6e 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, @@ -253,6 +253,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 @@ -562,6 +577,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 ae65aee92..c0026785f 100644 --- a/crates/validator/src/error.rs +++ b/crates/validator/src/error.rs @@ -85,6 +85,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), @@ -167,6 +171,7 @@ impl Error { // A refusal repeats itself, and a record that could not be written // needs an operator: retrying within the slot helps neither. 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..81296d6b8 --- /dev/null +++ b/crates/validator/src/exit.rs @@ -0,0 +1,779 @@ +//! `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. +//! +//! 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 +//! 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::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; +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"; + +/// 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 { + /// 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, 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, + /// 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` and when the + /// chain had not processed the exit by the end of the 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, + })?; + // 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 { + 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); + // 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, current_epoch)?; + 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 }) +} + +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 +/// 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 { + 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 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)", + 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, 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, +) -> Option { + let slot_duration = clock.slot_duration(); + for _ in 0..WAIT_EPOCHS * SLOTS_PER_EPOCH { + tokio::time::sleep(slot_duration).await; + 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" => { + // 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(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. \ + 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." + ); + } + "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::Io { + path: "stdout".to_string(), + source: err.into(), + })?; + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::time::Duration; + + use ethlambda_types::beacon::config::Config; + 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()); + } + + 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 = now() - 1; + let eligible = activation + Config::mainnet().shard_committee_period; + let node = node_with(active(activation)); + let no_prompt = ExitOptions { + no_confirmation: true, + no_wait: true, + ..ExitOptions::default() + }; + 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] + 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, now()); + 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"); + assert_eq!(node.submitted_exits()[0].message.epoch, now()); + } + + fn exiting(status: &str) -> ValidatorDetails { + ValidatorDetails { + status: status.to_string(), + exit_epoch: 1_000_000, + withdrawable_epoch: 1_000_256, + ..active(0) + } + } + + fn waits() -> ExitOptions { + ExitOptions { + no_confirmation: true, + ..ExitOptions::default() + } + } + + /// 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); + // 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, outage); + + assert_eq!( + outcome.expect("published"), + Outcome::Published { + 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/keystores.rs b/crates/validator/src/http_api/keystores.rs index c8356decb..60221587f 100644 --- a/crates/validator/src/http_api/keystores.rs +++ b/crates/validator/src/http_api/keystores.rs @@ -1017,6 +1017,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( @@ -1026,6 +1027,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, definitions_unflushed: Arc::new(std::sync::atomic::AtomicBool::new(false)), protection: crate::slashing_protection::SlashingProtection::in_memory(), } diff --git a/crates/validator/src/http_api/mod.rs b/crates/validator/src/http_api/mod.rs index 189e5ac7a..a00d3fdbc 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, /// Whether the last save of the definitions file replaced it without /// flushing the directory, so its last change may not survive a power /// loss. Set and cleared by `keystores::save_definitions`; read where a @@ -160,6 +171,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))) @@ -224,8 +239,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 91c5ab5e4..b5d1a787f 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, definitions_unflushed: Arc::new(std::sync::atomic::AtomicBool::new(false)), protection: crate::slashing_protection::SlashingProtection::in_memory(), }; 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..1aa624270 --- /dev/null +++ b/crates/validator/src/http_api/voluntary_exit.rs @@ -0,0 +1,356 @@ +//! `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. 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; + +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 current_epoch = context.clock.now().map(|slot| context.clock.epoch_of(slot)); + let epoch = match &query.epoch { + Some(text) => parse_uint64("epoch", text)?, + 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 + // 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, + }; + // 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, current_epoch) + .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 { + 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(); + 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() + - epochs * 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(())), + definitions_unflushed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + protection: crate::slashing_protection::SlashingProtection::in_memory(), + 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 + .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, now) + )); + // 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"); + } + + /// 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); + 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 24fc99dfd..1f24ba749 100644 --- a/crates/validator/src/lib.rs +++ b/crates/validator/src/lib.rs @@ -30,6 +30,7 @@ pub mod attestation; pub mod beacon_node; pub mod duties; pub mod error; +pub mod exit; pub mod http_api; pub mod keys; pub mod metrics; @@ -224,6 +225,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(), definitions_unflushed: Arc::new(std::sync::atomic::AtomicBool::new(false)), protection: protection.clone(), }; diff --git a/crates/validator/src/signing.rs b/crates/validator/src/signing.rs index 9317ab341..2c673521c 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,46 @@ impl SigningContext { compute_signing_root(message.hash_tree_root(), domain) } + /// The root a voluntary exit's signature is computed over. + /// + /// 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: + /// + /// - 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) + }; + 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 +404,28 @@ 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. + /// + /// `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 { + 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 /// behalf of `pubkey`. /// @@ -760,6 +825,146 @@ 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(); + 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, exit.epoch) + .expect("signs"); + assert!(verify( + &pubkey, + &signature, + context.voluntary_exit_signing_root(&exit, exit.epoch) + )); + } + + /// 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 = 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( + exit.hash_tree_root(), + context.domain(DOMAIN_VOLUNTARY_EXIT, epoch) + ), + "the fixture must cross a fork the schedule's version differs at" + ); + } + } + + /// 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, for an exit processed in that fork. + #[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, exit.epoch), + compute_signing_root( + exit.hash_tree_root(), + context.domain(DOMAIN_VOLUNTARY_EXIT, exit.epoch) + ) + ); + } + + /// 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/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 e77aa426a..827ea01fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -296,6 +296,62 @@ implementation's, so none of the [common flags](#common-flags) apply to it. | `--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 | | `--init-slashing-protection` | off | Register every loaded key with no slashing-protection record as never having signed, instead of refusing to start. Dangerous: only for keys that have signed nothing anywhere for at least two epochs, and even then it rules out a double vote only, not a surround of an older vote; a key with a history should bring it instead. Never lowers an existing record; remove it once the keys are registered, and startup warns while it is set for nothing | +### `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, 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 | + +**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, +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, 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 Attestations, block proposals and attestation aggregation, and from gloas on @@ -415,7 +471,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: