diff --git a/bin/ethlambda/src/command.rs b/bin/ethlambda/src/command.rs index d5cfe4d54..0b9199b1f 100644 --- a/bin/ethlambda/src/command.rs +++ b/bin/ethlambda/src/command.rs @@ -30,6 +30,7 @@ const EXPLICIT: &[&str] = &[ BENCHMARK, REPLAY_ERA, VALIDATOR, + VOLUNTARY_EXIT, SLASHING_PROTECTION, KEYGEN, "help", @@ -44,6 +45,7 @@ const BEACON: &str = "beacon"; const BENCHMARK: &str = "benchmark"; const REPLAY_ERA: &str = "replay-era"; const VALIDATOR: &str = "validator"; +const VOLUNTARY_EXIT: &str = "voluntary-exit"; const SLASHING_PROTECTION: &str = "slashing-protection"; const KEYGEN: &str = "keygen"; @@ -86,6 +88,11 @@ pub(crate) enum Command { ReplayEra(ReplayEraOptions), /// Run the beacon-chain validator client against a beacon node's HTTP API. Validator(crate::validator::ValidatorOptions), + /// 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. + VoluntaryExit(crate::voluntary_exit::VoluntaryExitOptions), /// Import or export the validator client's EIP-3076 slashing-protection /// history, offline. SlashingProtection(crate::slashing_protection::SlashingProtectionOptions), @@ -207,6 +214,36 @@ mod tests { assert_eq!(options.validators_dir, PathBuf::from("/tmp/validators")); } + /// `voluntary-exit` is a command of its own, so it takes only its own + /// flags and the validator client's required ones are not asked of it. + #[test] + fn voluntary_exit_parses_as_its_own_sub_command() { + let args = [ + "ethlambda", + VOLUNTARY_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::VoluntaryExit(exit) = command else { + panic!("expected a voluntary-exit invocation, got {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); + } + /// The historical flat form must keep working, unchanged. #[test] fn the_flat_node_form_still_parses_after_adding_validator() { @@ -425,6 +462,7 @@ mod tests { BEACON, BENCHMARK, VALIDATOR, + VOLUNTARY_EXIT, SLASHING_PROTECTION, KEYGEN, ] { diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index db3f62368..72c99fe07 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -11,6 +11,7 @@ mod network; mod slashing_protection; mod validator; mod version; +mod voluntary_exit; // Jemalloc causes programs to deadlock during process startup under Shadow. // See https://github.com/shadow/shadow/issues/3763. Build the Shadow binary @@ -110,6 +111,12 @@ fn main() -> eyre::Result<()> { init_node_logging()?; validator::run(options) } + // A one-shot command whose stdout may carry a pre-signed exit, so its + // logs go to stderr like keygen's. + Command::VoluntaryExit(options) => { + init_info_stderr_logging()?; + voluntary_exit::run(options) + } // Offline file work: no runtime, and stdout left alone. Command::SlashingProtection(options) => { init_info_stderr_logging()?; diff --git a/bin/ethlambda/src/voluntary_exit.rs b/bin/ethlambda/src/voluntary_exit.rs new file mode 100644 index 000000000..acbe75060 --- /dev/null +++ b/bin/ethlambda/src/voluntary_exit.rs @@ -0,0 +1,79 @@ +//! `ethlambda voluntary-exit`: sign one validator's voluntary exit and hand it +//! to a beacon node. +//! +//! A command of its own rather than part of `ethlambda validator`: it takes a +//! single keystore and none of the client's flags, and it runs once and ends. +//! Like the client 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. + +use std::path::PathBuf; + +use ethlambda_validator::exit::{self, CONFIRMATION_PHRASE, ExitConfig, ExitOptions, Outcome}; + +#[derive(Debug, clap::Args)] +pub(crate) struct VoluntaryExitOptions { + /// 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, +} + +/// Sign and publish one validator's voluntary exit, on a runtime of its own: +/// each sub-command picks its concurrency model, and `main` stays synchronous. +/// +/// 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 run(options: VoluntaryExitOptions) -> eyre::Result<()> { + let outcome = exit::run(ExitConfig { + keystore: options.keystore, + password_file: options.password_file, + beacon_node: options.beacon_node, + options: 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(()) +} diff --git a/crates/net/rpc/src/beacon/validator_client_tests.rs b/crates/net/rpc/src/beacon/validator_client_tests.rs index b62b61cdc..9ee54d25d 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 voluntary-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..193eb3953 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}; @@ -308,9 +309,14 @@ impl HttpBeaconNode { } } - /// A GET whose success may be a 204, which is an answer ("nothing to - /// report") and not a failure. - async fn get_optional(&self, path: &str) -> Result> { + /// A GET for which the `absent` status is an answer ("nothing to report") + /// and not a failure: a 204 from an endpoint that has nothing yet, a 404 + /// from one asked about something the node does not know. + async fn get_optional( + &self, + path: &str, + absent: StatusCode, + ) -> Result> { let url = format!("{}{path}", self.base_url); debug!(%url, "Beacon API GET"); let response = self @@ -323,7 +329,7 @@ impl HttpBeaconNode { failure: BeaconNodeFailure::classify(&err), detail: err.to_string(), })?; - if response.status() == StatusCode::NO_CONTENT { + if response.status() == absent { return Ok(None); } Self::decode(response).await.map(Some) @@ -770,11 +776,9 @@ impl BeaconNodeApi for HttpBeaconNode { &self, slot: Slot, ) -> Result> { - let response: Option> = self - .get_optional(&format!( - "/eth/v1/validator/payload_attestation_data?slot={slot}" - )) - .await?; + let path = format!("/eth/v1/validator/payload_attestation_data?slot={slot}"); + let response: Option> = + self.get_optional(&path, StatusCode::NO_CONTENT).await?; let Some(response) = response else { return Ok(None); }; @@ -1047,6 +1051,43 @@ impl BeaconNodeApi for HttpBeaconNode { ) .await } + + async fn validator(&self, pubkey: &BlsPubkey) -> Result> { + let path = format!( + "/eth/v1/beacon/states/head/validators/{}", + encode_hex(&pubkey.0) + ); + // A 404 is this endpoint's "validator not found", per the + // specification. + let response: Option> = + self.get_optional(&path, StatusCode::NOT_FOUND).await?; + let Some(response) = response else { + return Ok(None); + }; + 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..5cf4db23b 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 voluntary-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..6f71a2a15 --- /dev/null +++ b/crates/validator/src/exit.rs @@ -0,0 +1,755 @@ +//! `ethlambda voluntary-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 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::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, read from disk +/// exactly as the validator client reads its own +/// ([`ValidatorStore::insert_keystore_file`]). +pub fn load_keystore( + keystore_path: &Path, + password_path: &Path, +) -> Result<(ValidatorStore, BlsPubkey)> { + let mut store = ValidatorStore::new(); + let pubkey = store.insert_keystore_file(keystore_path, password_path)?; + 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..a380106f6 --- /dev/null +++ b/crates/validator/src/http_api/voluntary_exit.rs @@ -0,0 +1,361 @@ +//! `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)?; + + // The same lookup the `voluntary-exit` command makes, so the two ways to + // sign an exit cannot disagree about a validator's index. + let entry = context + .beacon_node + .validator(&pubkey) + .await + .map_err(|err| { + ApiError::Internal(format!("could not resolve the validator index: {err}")) + })?; + let Some(entry) = entry 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::constants::FAR_FUTURE_EPOCH; + 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::ValidatorDetails; + 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 node = MockBeaconNode::new(); + if resolves { + node.set_validator_details(Some(ValidatorDetails { + index: INDEX, + status: "active_ongoing".to_string(), + activation_epoch: 0, + exit_epoch: FAR_FUTURE_EPOCH, + withdrawable_epoch: FAR_FUTURE_EPOCH, + })); + } + 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/keys/store.rs b/crates/validator/src/keys/store.rs index 216855253..db45bb861 100644 --- a/crates/validator/src/keys/store.rs +++ b/crates/validator/src/keys/store.rs @@ -6,6 +6,7 @@ use std::path::Path; use blst::min_pk::SecretKey; use ethlambda_types::beacon::primitives::{BLS_PUBKEY_SIZE, BlsPubkey}; use tracing::info; +use zeroize::Zeroizing; use crate::beacon_node::dto::{encode_hex, parse_pubkey}; use crate::error::{Error, Result}; @@ -54,36 +55,7 @@ impl ValidatorStore { let keystore_path = resolve(validators_dir, &definition.voting_keystore_path); let password_path = resolve(validators_dir, &definition.voting_keystore_password_path); - 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, - })?; - // `normalize_password` inside `decrypt` already strips every C0 - // control code, newlines included, so this trim is redundant - // today. It stays anyway: trimming at the file-reading boundary - // is defensible on its own and does not depend on a deeper - // implementation detail continuing to hold. Do not delete it as - // dead code, and do not treat it as the only thing standing - // between a saved password file and a working decrypt. - // - // `decrypt` yields a `Zeroizing<[u8; 32]>`, scrubbed on drop. - // Deref it at the call below rather than copying it out. - 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 derived = store.insert_secret(&keystore_path.display().to_string(), &secret)?; + let derived = store.insert_keystore_file(&keystore_path, &password_path)?; // The definitions file's `voting_public_key` is a claim about what // is inside the keystore, and until here nothing checked it. Every @@ -129,6 +101,52 @@ impl ValidatorStore { Ok(store) } + /// Decrypt the keystore at `keystore_path` with the password in + /// `password_path` and add its key, answering the public key it holds. + /// + /// The one place a keystore and its password file are read from disk, for + /// [`Self::load`] and the `voluntary-exit` command alike, so the two cannot + /// come to read a password differently. The password file is read whole, + /// with a trailing newline trimmed: an editor leaves one, and a password + /// ending in a newline is not something anyone chose. + pub fn insert_keystore_file( + &mut self, + keystore_path: &Path, + password_path: &Path, + ) -> Result { + 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); + // `normalize_password` inside `decrypt` already strips every C0 + // control code, newlines included, so this trim is redundant today. + // It stays anyway: trimming at the file-reading boundary is + // defensible on its own and does not depend on a deeper + // implementation detail continuing to hold. Do not delete it as dead + // code, and do not treat it as the only thing standing between a + // saved password file and a working decrypt. + // + // `decrypt` yields a `Zeroizing<[u8; 32]>`, scrubbed on drop. Deref + // it at the call below rather than copying it out. + 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(), + })?; + self.insert_secret(&keystore_path.display().to_string(), &secret) + } + /// Add one secret key, deriving its public key. /// /// `origin` names where the key came from, a keystore path or the 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..1b2c1a853 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,7 +18,9 @@ what each workload measures; `benchmark import`'s flags are in [their own section](#benchmark-import-flags) below. `ethlambda validator` is the validator client: a separate process that holds keys and performs duties against a beacon node over the standard REST API, documented in -[its own section](#validator-flags) below. `ethlambda slashing-protection` +[its own section](#validator-flags) below. `ethlambda voluntary-exit` signs one +validator's voluntary exit and hands it to a beacon node; see +[its section](#voluntary-exit) below. `ethlambda slashing-protection` moves that client's EIP-3076 history in and out of its directory, offline; see [its section](#slashing-protection) below. @@ -415,7 +417,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: @@ -455,6 +466,63 @@ beacon chain. Every series is registered at startup so it reads zero rather than being absent before its first event, which is what lets an alert fire on "attestations stopped". See [Metrics](./metrics.md#validator-client). +## `voluntary-exit` + +`ethlambda voluntary-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 is +a command of its own rather than part of `ethlambda validator`: it takes one +keystore and none of the client's flags, runs once and ends. + +```sh +ethlambda voluntary-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](#the-keymanager-api)'s `voluntary_exit` route does too. + ## `slashing-protection` Moves the validator client's EIP-3076 history in and out of its