From b39ce29425c3522e9231b6765fa3bf3aa9dd61b4 Mon Sep 17 00:00:00 2001 From: Tumas Date: Thu, 28 Mar 2024 12:41:14 +0200 Subject: [PATCH] Add /eth/v1/validator/{pubkey}/voluntary_exit endpoint --- Cargo.lock | 1 + runtime/src/runtime.rs | 8 +- signer/src/types.rs | 3 +- signer/src/web3signer/types.rs | 3 + validator/Cargo.toml | 1 + validator/src/api.rs | 148 +++++++++++++++++++++++++++++---- 6 files changed, 143 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2d8744b..02b530a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8175,6 +8175,7 @@ dependencies = [ "futures", "helper_functions", "hex", + "http_api_utils", "interop", "itertools 0.12.1", "jwt-simple", diff --git a/runtime/src/runtime.rs b/runtime/src/runtime.rs index bc4a978c..cd77b453 100644 --- a/runtime/src/runtime.rs +++ b/runtime/src/runtime.rs @@ -473,7 +473,7 @@ pub async fn run_after_genesis( attestation_agg_pool.clone_arc(), builder_api, keymanager.proposer_configs().clone_arc(), - signer, + signer.clone_arc(), slashing_protector, sync_committee_agg_pool.clone_arc(), bls_to_execution_change_pool.clone_arc(), @@ -558,7 +558,7 @@ pub async fn run_after_genesis( let run_metrics_server = match metrics_server_config { Some(config) => Either::Left(run_metrics_server( config, - controller, + controller.clone_arc(), registry.take(), metrics.expect("Metrics registry must be present for metrics server"), metrics_to_metrics_tx, @@ -580,8 +580,10 @@ pub async fn run_after_genesis( let run_validator_api = match validator_api_config { Some(validator_api_config) => Either::Left(run_validator_api( validator_api_config, - keymanager, + controller, directories, + keymanager, + signer, )), None => Either::Right(core::future::pending()), }; diff --git a/signer/src/types.rs b/signer/src/types.rs index e11d1993..b43e45c8 100644 --- a/signer/src/types.rs +++ b/signer/src/types.rs @@ -18,7 +18,7 @@ use types::{ phase0::{ containers::{ AggregateAndProof, AttestationData, BeaconBlock as Phase0BeaconBlock, - BeaconBlockHeader, Fork, + BeaconBlockHeader, Fork, VoluntaryExit, }, primitives::{Epoch, Slot, H256}, }, @@ -73,6 +73,7 @@ pub enum SigningMessage<'block, P: Preset> { SyncAggregatorSelectionData(SyncAggregatorSelectionData), ContributionAndProof(ContributionAndProof

), ValidatorRegistration(ValidatorRegistrationV1), + VoluntaryExit(VoluntaryExit), } impl<'block, P: Preset> From<&'block Phase0BeaconBlock

> for SigningMessage<'block, P> { diff --git a/signer/src/web3signer/types.rs b/signer/src/web3signer/types.rs index cc654a12..eb68299d 100644 --- a/signer/src/web3signer/types.rs +++ b/signer/src/web3signer/types.rs @@ -38,6 +38,7 @@ impl<'block, P: Preset> SigningRequest<'block, P> { MessageType::SyncCommitteeContributionAndProof } SigningMessage::ValidatorRegistration(_) => MessageType::ValidatorRegistration, + SigningMessage::VoluntaryExit(_) => MessageType::VoluntaryExit, }; Self { @@ -62,6 +63,7 @@ enum MessageType { SyncCommitteeSelectionProof, SyncCommitteeContributionAndProof, ValidatorRegistration, + VoluntaryExit, } #[derive(Debug, Deserialize)] @@ -88,6 +90,7 @@ mod tests { "SYNC_COMMITTEE_SELECTION_PROOF", "SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF", "VALIDATOR_REGISTRATION", + "VOLUNTARY_EXIT", ], ); } diff --git a/validator/Cargo.toml b/validator/Cargo.toml index 3073ac3b..a3bca003 100644 --- a/validator/Cargo.toml +++ b/validator/Cargo.toml @@ -29,6 +29,7 @@ fs-err = { workspace = true } futures = { workspace = true } helper_functions = { workspace = true } hex = { workspace = true } +http_api_utils = { workspace = true } itertools = { workspace = true } jwt-simple = { workspace = true } keymanager = { workspace = true } diff --git a/validator/src/api.rs b/validator/src/api.rs index 0f5d02fe..61807f08 100644 --- a/validator/src/api.rs +++ b/validator/src/api.rs @@ -9,7 +9,7 @@ use anyhow::{Error as AnyhowError, Result}; use axum::{ async_trait, body::Body, - extract::{FromRef, FromRequest, FromRequestParts, Path as RequestPath, State}, + extract::{FromRef, FromRequest, FromRequestParts, Path as RequestPath, Query, State}, headers::{authorization::Bearer, Authorization}, http::{request::Parts, Request, StatusCode}, middleware::Next, @@ -20,6 +20,9 @@ use axum::{ use bls::PublicKeyBytes; use directories::Directories; use educe::Educe; +use eth1_api::ApiController; +use fork_choice_control::Wait; +use helper_functions::{accessors, signing::SignForSingleFork}; use jwt_simple::{ algorithms::{HS256Key, MACLike as _}, claims::{JWTClaims, NoCustomClaims}, @@ -27,11 +30,20 @@ use jwt_simple::{ }; use keymanager::{KeyManager, KeymanagerOperationStatus, RemoteKey, ValidatingPubkey}; use log::{debug, info}; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use signer::{Signer, SigningMessage}; use std_ext::ArcExt as _; use thiserror::Error; +use tokio::sync::RwLock; use tower_http::cors::AllowOrigin; -use types::{bellatrix::primitives::Gas, phase0::primitives::ExecutionAddress}; +use types::{ + bellatrix::primitives::Gas, + phase0::{ + containers::{SignedVoluntaryExit, VoluntaryExit}, + primitives::{Epoch, ExecutionAddress}, + }, + preset::Preset, +}; use zeroize::Zeroizing; const VALIDATOR_API_TOKEN_PATH: &str = "api-token.txt"; @@ -71,14 +83,25 @@ enum Error { InvalidJsonBody(#[source] AnyhowError), #[error("invalid public key")] InvalidPublicKey(#[source] AnyhowError), + #[error("invalid query string")] + InvalidQuery(#[source] AnyhowError), #[error("authentication error")] Unauthorized(#[source] AnyhowError), + #[error("validator {pubkey} not found")] + ValidatorNotFound { pubkey: PublicKeyBytes }, + #[error("validator {pubkey} is not managed by validator client")] + ValidatorNotOwned { pubkey: PublicKeyBytes }, } impl IntoResponse for Error { fn into_response(self) -> Response { match self { - Self::InvalidJsonBody(_) | Self::InvalidPublicKey(_) => StatusCode::BAD_REQUEST, + Self::InvalidJsonBody(_) | Self::InvalidPublicKey(_) | Self::InvalidQuery(_) => { + StatusCode::BAD_REQUEST + } + Self::ValidatorNotFound { pubkey: _ } | Self::ValidatorNotOwned { pubkey: _ } => { + StatusCode::NOT_FOUND + } Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::Unauthorized(_) => StatusCode::UNAUTHORIZED, } @@ -252,24 +275,54 @@ impl FromRequestParts for EthPath { } } +struct EthQuery(pub T); + +#[async_trait] +impl FromRequestParts for EthQuery { + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extract() + .await + .map(|Query(query)| Self(query)) + .map_err(AnyhowError::msg) + .map_err(Error::InvalidQuery) + } +} + #[derive(Clone)] -struct ValidatorApiState { +struct ValidatorApiState { + controller: ApiController, keymanager: Arc, secret: Arc, + signer: Arc>, +} + +impl FromRef> for ApiController { + fn from_ref(state: &ValidatorApiState) -> Self { + state.controller.clone_arc() + } } -impl FromRef for Arc { - fn from_ref(state: &ValidatorApiState) -> Self { +impl FromRef> for Arc { + fn from_ref(state: &ValidatorApiState) -> Self { state.keymanager.clone_arc() } } -impl FromRef for Arc { - fn from_ref(state: &ValidatorApiState) -> Self { +impl FromRef> for Arc { + fn from_ref(state: &ValidatorApiState) -> Self { state.secret.clone_arc() } } +impl FromRef> for Arc> { + fn from_ref(state: &ValidatorApiState) -> Self { + state.signer.clone_arc() + } +} + #[derive(Deserialize)] struct SetFeeRecipientQuery { ethaddress: ExecutionAddress, @@ -319,6 +372,11 @@ struct ProposerConfigResponse { graffiti: Option, } +#[derive(Deserialize)] +struct CreateVoluntaryExitQuery { + epoch: Option, +} + /// `GET /eth/v1/validator/{pubkey}/feerecipient` async fn keymanager_list_fee_recipient( State(keymanager): State>, @@ -518,6 +576,48 @@ async fn keymanager_delete_remote_keys( Ok(EthResponse::json(delete_statuses)) } +/// `POST /eth/v1/validator/{pubkey}/voluntary_exit` +async fn keymanager_create_voluntary_exit( + State(controller): State>, + State(signer): State>>, + EthPath(pubkey): EthPath, + EthQuery(query): EthQuery, +) -> Result, Error> { + let state = controller.preprocessed_state_at_current_slot()?; + + let epoch = query + .epoch + .unwrap_or_else(|| accessors::get_current_epoch(&state)); + + if !signer.read().await.has_key(pubkey) { + return Err(Error::ValidatorNotOwned { pubkey }); + } + + let validator_index = accessors::index_of_public_key(&state, pubkey) + .ok_or(Error::ValidatorNotFound { pubkey })?; + + let voluntary_exit = VoluntaryExit { + epoch, + validator_index, + }; + + let signature = signer + .read() + .await + .sign( + SigningMessage::VoluntaryExit(voluntary_exit), + voluntary_exit.signing_root(controller.chain_config(), &state), + Some(state.as_ref().into()), + pubkey, + ) + .await?; + + Ok(EthResponse::json(SignedVoluntaryExit { + message: voluntary_exit, + signature: signature.into(), + })) +} + async fn authorize_token( State(secret): State>, TypedHeader(auth): TypedHeader>, @@ -534,21 +634,28 @@ async fn authorize_token( } #[allow(clippy::module_name_repetitions)] -pub async fn run_validator_api( +pub async fn run_validator_api( validator_api_config: ValidatorApiConfig, - keymanager: Arc, + controller: ApiController, directories: Arc, + keymanager: Arc, + signer: Arc>, ) -> Result<()> { let Auth { secret, token } = load_or_build_auth_token(&directories)?; - info!( - "Validator API is listening on {}, authorization token: {token}", - validator_api_config.address - ); + let ValidatorApiConfig { + address, + allow_origin, + timeout, + } = validator_api_config; + + info!("Validator API is listening on {address}, authorization token: {token}"); let state = ValidatorApiState { + controller, keymanager, secret: Arc::new(secret), + signer, }; let router = eth_v1_keymanager_routes() @@ -558,13 +665,16 @@ pub async fn run_validator_api( )) .with_state(state); - Server::bind(&validator_api_config.address) + let router = + http_api_utils::extend_router_with_middleware(router, Some(timeout), allow_origin, None); + + Server::bind(&address) .serve(router.into_make_service_with_connect_info::()) .await .map_err(AnyhowError::new) } -fn eth_v1_keymanager_routes() -> Router { +fn eth_v1_keymanager_routes() -> Router> { Router::new() .route( "/eth/v1/validator/:pubkey/feerecipient", @@ -602,6 +712,10 @@ fn eth_v1_keymanager_routes() -> Router { "/eth/v1/validator/:pubkey/graffiti", delete(keymanager_delete_graffiti), ) + .route( + "/eth/v1/validator/:pubkey/voluntary_exit", + post(keymanager_create_voluntary_exit), + ) .route("/eth/v1/keystores", get(keymanager_list_validating_pubkeys)) .route("/eth/v1/keystores", post(keymanager_import_keystores)) .route("/eth/v1/keystores", delete(keymanager_delete_keystores))