diff --git a/common/eth2/src/lighthouse_vc/http_client.rs b/common/eth2/src/lighthouse_vc/http_client.rs index b2d53c5e0..2e6756c63 100644 --- a/common/eth2/src/lighthouse_vc/http_client.rs +++ b/common/eth2/src/lighthouse_vc/http_client.rs @@ -226,11 +226,32 @@ impl ValidatorClientHttpClient { ok_or_error(response).await } + /// Perform a HTTP DELETE request, returning the `Response` for further processing. + async fn delete_response(&self, url: U) -> Result { + let response = self + .client + .delete(url) + .headers(self.headers()?) + .send() + .await + .map_err(Error::from)?; + ok_or_error(response).await + } + async fn get(&self, url: U) -> Result { let response = self.get_response(url).await?; self.signed_json(response).await } + async fn delete(&self, url: U) -> Result<(), Error> { + let response = self.delete_response(url).await?; + if response.status().is_success() { + Ok(()) + } else { + Err(Error::StatusCode(response.status())) + } + } + async fn get_unsigned(&self, url: U) -> Result { self.get_response(url) .await? @@ -537,6 +558,18 @@ impl ValidatorClientHttpClient { Ok(url) } + fn make_graffiti_url(&self, pubkey: &PublicKeyBytes) -> Result { + let mut url = self.server.full.clone(); + url.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("validator") + .push(&pubkey.to_string()) + .push("graffiti"); + Ok(url) + } + fn make_gas_limit_url(&self, pubkey: &PublicKeyBytes) -> Result { let mut url = self.server.full.clone(); url.path_segments_mut() @@ -684,6 +717,34 @@ impl ValidatorClientHttpClient { self.post(path, &()).await } + + /// `GET /eth/v1/validator/{pubkey}/graffiti` + pub async fn get_graffiti( + &self, + pubkey: &PublicKeyBytes, + ) -> Result { + let url = self.make_graffiti_url(pubkey)?; + self.get(url) + .await + .map(|generic: GenericResponse| generic.data) + } + + /// `POST /eth/v1/validator/{pubkey}/graffiti` + pub async fn set_graffiti( + &self, + pubkey: &PublicKeyBytes, + graffiti: GraffitiString, + ) -> Result<(), Error> { + let url = self.make_graffiti_url(pubkey)?; + let set_graffiti_request = SetGraffitiRequest { graffiti }; + self.post(url, &set_graffiti_request).await + } + + /// `DELETE /eth/v1/validator/{pubkey}/graffiti` + pub async fn delete_graffiti(&self, pubkey: &PublicKeyBytes) -> Result<(), Error> { + let url = self.make_graffiti_url(pubkey)?; + self.delete(url).await + } } /// Returns `Ok(response)` if the response is a `200 OK` response or a diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index 33e2f764e..ab90d336f 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -1,7 +1,7 @@ use account_utils::ZeroizeString; use eth2_keystore::Keystore; use serde::{Deserialize, Serialize}; -use types::{Address, PublicKeyBytes}; +use types::{Address, Graffiti, PublicKeyBytes}; pub use slashing_protection::interchange::Interchange; @@ -172,3 +172,9 @@ pub enum DeleteRemotekeyStatus { pub struct DeleteRemotekeysResponse { pub data: Vec>, } + +#[derive(Debug, Deserialize, Serialize)] +pub struct GetGraffitiResponse { + pub pubkey: PublicKeyBytes, + pub graffiti: Graffiti, +} diff --git a/common/eth2/src/lighthouse_vc/types.rs b/common/eth2/src/lighthouse_vc/types.rs index f1a91b4ef..230293f1b 100644 --- a/common/eth2/src/lighthouse_vc/types.rs +++ b/common/eth2/src/lighthouse_vc/types.rs @@ -168,3 +168,8 @@ pub struct SingleExportKeystoresResponse { #[serde(skip_serializing_if = "Option::is_none")] pub validating_keystore_password: Option, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct SetGraffitiRequest { + pub graffiti: GraffitiString, +} diff --git a/validator_client/src/http_api/graffiti.rs b/validator_client/src/http_api/graffiti.rs new file mode 100644 index 000000000..79d4fd61f --- /dev/null +++ b/validator_client/src/http_api/graffiti.rs @@ -0,0 +1,80 @@ +use crate::validator_store::ValidatorStore; +use bls::PublicKey; +use slot_clock::SlotClock; +use std::sync::Arc; +use types::{graffiti::GraffitiString, EthSpec, Graffiti}; + +pub fn get_graffiti( + validator_pubkey: PublicKey, + validator_store: Arc>, + graffiti_flag: Option, +) -> Result { + let initialized_validators_rw_lock = validator_store.initialized_validators(); + let initialized_validators = initialized_validators_rw_lock.read(); + match initialized_validators.validator(&validator_pubkey.compress()) { + None => Err(warp_utils::reject::custom_not_found( + "The key was not found on the server".to_string(), + )), + Some(_) => { + let Some(graffiti) = initialized_validators.graffiti(&validator_pubkey.into()) else { + return graffiti_flag.ok_or(warp_utils::reject::custom_server_error( + "No graffiti found, unable to return the process-wide default".to_string(), + )); + }; + Ok(graffiti) + } + } +} + +pub fn set_graffiti( + validator_pubkey: PublicKey, + graffiti: GraffitiString, + validator_store: Arc>, +) -> Result<(), warp::Rejection> { + let initialized_validators_rw_lock = validator_store.initialized_validators(); + let mut initialized_validators = initialized_validators_rw_lock.write(); + match initialized_validators.validator(&validator_pubkey.compress()) { + None => Err(warp_utils::reject::custom_not_found( + "The key was not found on the server, nothing to update".to_string(), + )), + Some(initialized_validator) => { + if initialized_validator.get_graffiti() == Some(graffiti.clone().into()) { + Ok(()) + } else { + initialized_validators + .set_graffiti(&validator_pubkey, graffiti) + .map_err(|_| { + warp_utils::reject::custom_server_error( + "A graffiti was found, but failed to be updated.".to_string(), + ) + }) + } + } + } +} + +pub fn delete_graffiti( + validator_pubkey: PublicKey, + validator_store: Arc>, +) -> Result<(), warp::Rejection> { + let initialized_validators_rw_lock = validator_store.initialized_validators(); + let mut initialized_validators = initialized_validators_rw_lock.write(); + match initialized_validators.validator(&validator_pubkey.compress()) { + None => Err(warp_utils::reject::custom_not_found( + "The key was not found on the server, nothing to delete".to_string(), + )), + Some(initialized_validator) => { + if initialized_validator.get_graffiti().is_none() { + Ok(()) + } else { + initialized_validators + .delete_graffiti(&validator_pubkey) + .map_err(|_| { + warp_utils::reject::custom_server_error( + "A graffiti was found, but failed to be removed.".to_string(), + ) + }) + } + } + } +} diff --git a/validator_client/src/http_api/mod.rs b/validator_client/src/http_api/mod.rs index 669edc671..c65beb739 100644 --- a/validator_client/src/http_api/mod.rs +++ b/validator_client/src/http_api/mod.rs @@ -1,12 +1,15 @@ mod api_secret; mod create_signed_voluntary_exit; mod create_validator; +mod graffiti; mod keystores; mod remotekeys; mod tests; pub mod test_utils; +use crate::http_api::graffiti::{delete_graffiti, get_graffiti, set_graffiti}; + use crate::http_api::create_signed_voluntary_exit::create_signed_voluntary_exit; use crate::{determine_graffiti, GraffitiFile, ValidatorStore}; use account_utils::{ @@ -19,7 +22,10 @@ use create_validator::{ }; use eth2::lighthouse_vc::{ std_types::{AuthResponse, GetFeeRecipientResponse, GetGasLimitResponse}, - types::{self as api_types, GenericResponse, Graffiti, PublicKey, PublicKeyBytes}, + types::{ + self as api_types, GenericResponse, GetGraffitiResponse, Graffiti, PublicKey, + PublicKeyBytes, SetGraffitiRequest, + }, }; use lighthouse_version::version_with_platform; use logging::SSELoggingComponents; @@ -653,7 +659,7 @@ pub fn serve( .and(warp::path::end()) .and(warp::body::json()) .and(validator_store_filter.clone()) - .and(graffiti_file_filter) + .and(graffiti_file_filter.clone()) .and(signer.clone()) .and(task_executor_filter.clone()) .and_then( @@ -1028,6 +1034,86 @@ pub fn serve( }, ); + // GET /eth/v1/validator/{pubkey}/graffiti + let get_graffiti = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("graffiti")) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(graffiti_flag_filter) + .and(signer.clone()) + .and_then( + |pubkey: PublicKey, + validator_store: Arc>, + graffiti_flag: Option, + signer| { + blocking_signed_json_task(signer, move || { + let graffiti = get_graffiti(pubkey.clone(), validator_store, graffiti_flag)?; + Ok(GenericResponse::from(GetGraffitiResponse { + pubkey: pubkey.into(), + graffiti, + })) + }) + }, + ); + + // POST /eth/v1/validator/{pubkey}/graffiti + let post_graffiti = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("graffiti")) + .and(warp::body::json()) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(graffiti_file_filter.clone()) + .and(signer.clone()) + .and_then( + |pubkey: PublicKey, + query: SetGraffitiRequest, + validator_store: Arc>, + graffiti_file: Option, + signer| { + blocking_signed_json_task(signer, move || { + if graffiti_file.is_some() { + return Err(warp_utils::reject::invalid_auth( + "Unable to update graffiti as the \"--graffiti-file\" flag is set" + .to_string(), + )); + } + set_graffiti(pubkey.clone(), query.graffiti, validator_store) + }) + }, + ) + .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::ACCEPTED)); + + // DELETE /eth/v1/validator/{pubkey}/graffiti + let delete_graffiti = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("graffiti")) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(graffiti_file_filter.clone()) + .and(signer.clone()) + .and_then( + |pubkey: PublicKey, + validator_store: Arc>, + graffiti_file: Option, + signer| { + blocking_signed_json_task(signer, move || { + if graffiti_file.is_some() { + return Err(warp_utils::reject::invalid_auth( + "Unable to delete graffiti as the \"--graffiti-file\" flag is set" + .to_string(), + )); + } + delete_graffiti(pubkey.clone(), validator_store) + }) + }, + ) + .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NO_CONTENT)); + // GET /eth/v1/keystores let get_std_keystores = std_keystores .and(signer.clone()) @@ -1175,6 +1261,7 @@ pub fn serve( .or(get_lighthouse_ui_graffiti) .or(get_fee_recipient) .or(get_gas_limit) + .or(get_graffiti) .or(get_std_keystores) .or(get_std_remotekeys) .recover(warp_utils::reject::handle_rejection), @@ -1189,6 +1276,7 @@ pub fn serve( .or(post_gas_limit) .or(post_std_keystores) .or(post_std_remotekeys) + .or(post_graffiti) .recover(warp_utils::reject::handle_rejection), )) .or(warp::patch() @@ -1199,6 +1287,7 @@ pub fn serve( .or(delete_gas_limit) .or(delete_std_keystores) .or(delete_std_remotekeys) + .or(delete_graffiti) .recover(warp_utils::reject::handle_rejection), )), ) diff --git a/validator_client/src/http_api/tests.rs b/validator_client/src/http_api/tests.rs index 5f59e35c7..7de3cea21 100644 --- a/validator_client/src/http_api/tests.rs +++ b/validator_client/src/http_api/tests.rs @@ -640,6 +640,49 @@ impl ApiTester { self } + + pub async fn test_set_graffiti(self, index: usize, graffiti: &str) -> Self { + let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index]; + let graffiti_str = GraffitiString::from_str(graffiti).unwrap(); + let resp = self + .client + .set_graffiti(&validator.voting_pubkey, graffiti_str) + .await; + + assert!(resp.is_ok()); + + self + } + + pub async fn test_delete_graffiti(self, index: usize) -> Self { + let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index]; + let resp = self.client.get_graffiti(&validator.voting_pubkey).await; + + assert!(resp.is_ok()); + let old_graffiti = resp.unwrap().graffiti; + + let resp = self.client.delete_graffiti(&validator.voting_pubkey).await; + + assert!(resp.is_ok()); + + let resp = self.client.get_graffiti(&validator.voting_pubkey).await; + + assert!(resp.is_ok()); + assert_ne!(old_graffiti, resp.unwrap().graffiti); + + self + } + + pub async fn test_get_graffiti(self, index: usize, expected_graffiti: &str) -> Self { + let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index]; + let expected_graffiti_str = GraffitiString::from_str(expected_graffiti).unwrap(); + let resp = self.client.get_graffiti(&validator.voting_pubkey).await; + + assert!(resp.is_ok()); + assert_eq!(&resp.unwrap().graffiti, &expected_graffiti_str.into()); + + self + } } struct HdValidatorScenario { @@ -771,6 +814,20 @@ async fn routes_with_invalid_auth() { }) .await }) + .await + .test_with_invalid_auth(|client| async move { + client.delete_graffiti(&PublicKeyBytes::empty()).await + }) + .await + .test_with_invalid_auth(|client| async move { + client.get_graffiti(&PublicKeyBytes::empty()).await + }) + .await + .test_with_invalid_auth(|client| async move { + client + .set_graffiti(&PublicKeyBytes::empty(), GraffitiString::default()) + .await + }) .await; } @@ -954,6 +1011,31 @@ async fn validator_graffiti() { .await; } +#[tokio::test] +async fn validator_graffiti_api() { + ApiTester::new() + .await + .create_hd_validators(HdValidatorScenario { + count: 2, + specify_mnemonic: false, + key_derivation_path_offset: 0, + disabled: vec![], + }) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(2) + .set_graffiti(0, "Mr F was here") + .await + .test_get_graffiti(0, "Mr F was here") + .await + .test_set_graffiti(0, "Uncle Bill was here") + .await + .test_get_graffiti(0, "Uncle Bill was here") + .await + .test_delete_graffiti(0) + .await; +} + #[tokio::test] async fn keystore_validator_creation() { ApiTester::new() diff --git a/validator_client/src/initialized_validators.rs b/validator_client/src/initialized_validators.rs index f15ea27c9..b65dad4c4 100644 --- a/validator_client/src/initialized_validators.rs +++ b/validator_client/src/initialized_validators.rs @@ -716,6 +716,74 @@ impl InitializedValidators { self.validators.get(public_key).and_then(|v| v.graffiti) } + /// Sets the `InitializedValidator` and `ValidatorDefinition` `graffiti` values. + /// + /// ## Notes + /// + /// Setting a validator `graffiti` will cause `self.definitions` to be updated and saved to + /// disk. + /// + /// Saves the `ValidatorDefinitions` to file, even if no definitions were changed. + pub fn set_graffiti( + &mut self, + voting_public_key: &PublicKey, + graffiti: GraffitiString, + ) -> Result<(), Error> { + if let Some(def) = self + .definitions + .as_mut_slice() + .iter_mut() + .find(|def| def.voting_public_key == *voting_public_key) + { + def.graffiti = Some(graffiti.clone()); + } + + if let Some(val) = self + .validators + .get_mut(&PublicKeyBytes::from(voting_public_key)) + { + val.graffiti = Some(graffiti.into()); + } + + self.definitions + .save(&self.validators_dir) + .map_err(Error::UnableToSaveDefinitions)?; + Ok(()) + } + + /// Removes the `InitializedValidator` and `ValidatorDefinition` `graffiti` values. + /// + /// ## Notes + /// + /// Removing a validator `graffiti` will cause `self.definitions` to be updated and saved to + /// disk. The graffiti for the validator will then fall back to the process level default if + /// it is set. + /// + /// Saves the `ValidatorDefinitions` to file, even if no definitions were changed. + pub fn delete_graffiti(&mut self, voting_public_key: &PublicKey) -> Result<(), Error> { + if let Some(def) = self + .definitions + .as_mut_slice() + .iter_mut() + .find(|def| def.voting_public_key == *voting_public_key) + { + def.graffiti = None; + } + + if let Some(val) = self + .validators + .get_mut(&PublicKeyBytes::from(voting_public_key)) + { + val.graffiti = None; + } + + self.definitions + .save(&self.validators_dir) + .map_err(Error::UnableToSaveDefinitions)?; + + Ok(()) + } + /// Returns a `HashMap` of `public_key` -> `graffiti` for all initialized validators. pub fn get_all_validators_graffiti(&self) -> HashMap<&PublicKeyBytes, Option> { let mut result = HashMap::new();