Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 304 additions & 1 deletion common/account_utils/src/validator_definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -212,6 +212,16 @@ impl ValidatorDefinition {
},
})
}

pub fn check_fee_recipient(&self, global_fee_recipient: Option<Address>) -> 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
Expand Down Expand Up @@ -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<Address>,
) -> 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::<Vec<_>>()
.join(", ");

return Err(format!(
"The following validators are missing a `suggested_fee_recipient`: {}. \
Fix this by adding a `suggested_fee_recipient` in your \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that the validator_definitions.yml reads better here, but feel free to take it or leave it

Suggested change
Fix this by adding a `suggested_fee_recipient` in your \
Fix this by adding a `suggested_fee_recipient` in the \

`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`.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is about "reducing the severity" of the log, because if users only use the suggested-fee-recipient flag, this log will appear every time a VC starts, so it could be a bit of a warn thingy.

This is a my two cent, keep to hear from the author and other developers about this. (e.g., maybe even shift to debug log)?

Suggested change
Consider setting the fee recipient for each validator individually via `validator_definitions.yml`.",
You may alternatively set 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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a nit for better clarity

Suggested change
let global_fee = Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap());
let global_fee_recipient = Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap());

let check_result = def.check_fee_recipient(global_fee);
assert!(check_result.is_none());
Comment on lines +791 to +792
Copy link
Member

@chong-he chong-he Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For succinctness, we can combine this with the test fee_recipient_check_fails_when_missing() to avoid repeatedly defining the same fields. We can revise the test name a bit, then add the checks in the same tests

Edit: I believe we can even go "all out" for succinctness, to define a general ValidatorDefinitions, then for each test, update the necessary fields for each test purpose, and add an assert right below, i.e., instead of having multiple tests, we can have just 1 or 2 test functions, move the test name (and revise a bit) and put it as a comment right above and before the assert. This will be cleaner as well

}

#[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());
}
}
4 changes: 4 additions & 0 deletions validator_client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ impl<E: EthSpec> ProductionValidatorClient<E> {
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(),
Expand Down
Loading