diff --git a/common/account_utils/src/validator_definitions.rs b/common/account_utils/src/validator_definitions.rs
index bffdfcc38bd..188f7ee5ab2 100644
--- a/common/account_utils/src/validator_definitions.rs
+++ b/common/account_utils/src/validator_definitions.rs
@@ -12,7 +12,7 @@ use std::collections::HashSet;
use std::fs::{self, File, create_dir_all};
use std::io;
use std::path::{Path, PathBuf};
-use tracing::error;
+use tracing::{error, info};
use types::{Address, graffiti::GraffitiString};
use validator_dir::VOTING_KEYSTORE_FILE;
use zeroize::Zeroizing;
@@ -212,6 +212,16 @@ impl ValidatorDefinition {
},
})
}
+
+ pub fn check_fee_recipient(&self, global_fee_recipient: Option
) -> Option<&PublicKey> {
+ // Skip disabled validators. Also skip if validator has its own fee set, or the global flag is set
+ if !self.enabled || self.suggested_fee_recipient.is_some() || global_fee_recipient.is_some()
+ {
+ return None;
+ }
+
+ Some(&self.voting_public_key)
+ }
}
/// A list of `ValidatorDefinition` that serves as a serde-able configuration file which defines a
@@ -410,6 +420,52 @@ impl ValidatorDefinitions {
.iter()
.filter_map(|def| def.signing_definition.voting_keystore_password_path())
}
+
+ /// Called after loading to run safety checks on all validators
+ pub fn check_all_fee_recipients(
+ &self,
+ global_fee_recipient: Option,
+ ) -> Result<(), String> {
+ let missing: Vec<&PublicKey> = self
+ .0
+ .iter()
+ .filter_map(|def| def.check_fee_recipient(global_fee_recipient))
+ .collect();
+
+ if !missing.is_empty() {
+ let pubkeys = missing
+ .iter()
+ .map(|pk| pk.to_string())
+ .collect::>()
+ .join(", ");
+
+ return Err(format!(
+ "The following validators are missing a `suggested_fee_recipient`: {}. \
+ Fix this by adding a `suggested_fee_recipient` in your \
+ `validator_definitions.yml` or by supplying a fallback fee \
+ recipient via the `--suggested-fee-recipient` flag.",
+ pubkeys
+ ));
+ }
+
+ // Friendly reminder for users using the fallback flag
+ if global_fee_recipient.is_some() {
+ let count = self
+ .0
+ .iter()
+ .filter(|d| d.enabled && d.suggested_fee_recipient.is_none())
+ .count();
+ if count > 0 {
+ info!(
+ "The fallback --suggested-fee-recipient is being used for {} validator(s). \
+ Consider setting the fee recipient for each validator individually via `validator_definitions.yml`.",
+ count
+ );
+ }
+ }
+
+ Ok(())
+ }
}
/// Perform an exhaustive tree search of `dir`, adding any discovered voting keystore paths to
@@ -486,6 +542,7 @@ pub fn is_voting_keystore(file_name: &str) -> bool {
mod tests {
use super::*;
use std::str::FromStr;
+ use bls::Keypair;
#[test]
fn voting_keystore_filename_lighthouse() {
@@ -682,4 +739,250 @@ mod tests {
let def: ValidatorDefinition = serde_yaml::from_str(valid_builder_proposals).unwrap();
assert_eq!(def.builder_proposals, Some(true));
}
+
+ #[test]
+ fn fee_recipient_check_fails_when_missing() {
+ let def = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: PublicKey::from_str(
+ "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7"
+ ).unwrap(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None,
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ }
+ };
+ // Should return Some(pubkey) when no fee recipient is set
+ let check_result = def.check_fee_recipient(None);
+ assert!(check_result.is_some());
+ }
+
+ #[test]
+ fn fee_recipient_check_passes_with_global_flag() {
+ let def = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: PublicKey::from_str(
+ "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7"
+ ).unwrap(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None,
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ // Should return None since global fee recipient is set
+ let global_fee = Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap());
+ let check_result = def.check_fee_recipient(global_fee);
+ assert!(check_result.is_none());
+ }
+
+ #[test]
+ fn fee_recipient_check_passes_with_validator_specific() {
+ let def = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: PublicKey::from_str(
+ "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7"
+ ).unwrap(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()),
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ // Should return None because suggested_fee_recipient is set
+ let check_result = def.check_fee_recipient(None);
+ assert!(check_result.is_none());
+ }
+
+ #[test]
+ fn fee_recipient_check_skips_disabled_validators() {
+ let def = ValidatorDefinition {
+ enabled: false,
+ voting_public_key: PublicKey::from_str(
+ "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7"
+ ).unwrap(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None, // No fee recipient
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ // Should return None because validator is disabled
+ let check_result = def.check_fee_recipient(None);
+ assert!(check_result.is_none());
+ }
+
+ #[test]
+ fn check_all_fee_recipients_reports_all_missing() {
+ let keypair1 = Keypair::random();
+ let keypair2 = Keypair::random();
+
+ let def1 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair1.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None,
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let def2 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair2.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None, // Missing recipient
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let defs = ValidatorDefinitions::from(vec![def1, def2]);
+
+ // Should fail because both defs have no fee recipient and no global fee recipient is set
+ let result = defs.check_all_fee_recipients(None);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+
+ // Check that both public keys are mentioned in the error message
+ let pk1_string = keypair1.pk.to_string();
+ let pk2_string = keypair2.pk.to_string();
+
+ assert!(err.contains(&pk1_string), "Error message missing pubkey 1");
+ assert!(err.contains(&pk2_string), "Error message missing pubkey 2");
+ assert!(err.contains("are missing a `suggested_fee_recipient`"));
+ }
+
+ #[test]
+ fn check_all_fee_recipients_passes_all_configured() {
+ let keypair = Keypair::random();
+ let def1 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()),
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let def2 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: Some(Address::from_str("0xb2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()),
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let defs = ValidatorDefinitions::from(vec![def1, def2]);
+
+ // Should pass - all validators have fee recipients
+ assert!(defs.check_all_fee_recipients(None).is_ok());
+ }
+
+ #[test]
+ fn check_all_fee_recipients_passes_with_global() {
+ let keypair = Keypair::random();
+ let def1 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None,
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let def2 = ValidatorDefinition {
+ enabled: true,
+ voting_public_key: keypair.pk.clone(),
+ description: String::new(),
+ graffiti: None,
+ suggested_fee_recipient: None,
+ gas_limit: None,
+ builder_proposals: None,
+ builder_boost_factor: None,
+ prefer_builder_proposals: None,
+ signing_definition: SigningDefinition::LocalKeystore {
+ voting_keystore_path: PathBuf::new(),
+ voting_keystore_password_path: None,
+ voting_keystore_password: None,
+ },
+ };
+
+ let defs = ValidatorDefinitions::from(vec![def1, def2]);
+
+ // Should pass - global fee recipient is set
+ let global_fee = Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap());
+ assert!(defs.check_all_fee_recipients(global_fee).is_ok());
+ }
}
diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs
index fe6a93ae9dd..6a0438b80e3 100644
--- a/validator_client/src/lib.rs
+++ b/validator_client/src/lib.rs
@@ -183,6 +183,10 @@ impl ProductionValidatorClient {
info!(new_validators, "Completed validator discovery");
}
+ // Check for all validators' fee recipient
+ validator_defs
+ .check_all_fee_recipients(config.validator_store.fee_recipient)?;
+
let validators = InitializedValidators::from_definitions(
validator_defs,
config.validator_dir.clone(),