Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
3 changes: 3 additions & 0 deletions book/src/help_vm_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Options:
--debug-level <LEVEL>
Specifies the verbosity level used when emitting logs to the terminal.
[default: info] [possible values: info, debug, trace, warn, error]
--enabled <enabled>
When provided, the imported validator will be enabled or disabled.
[possible values: true, false]
--gas-limit <UINT64>
When provided, the imported validator will use this gas limit. It is
recommended to leave this as the default value by not specifying this
Expand Down
65 changes: 65 additions & 0 deletions lighthouse/tests/validator_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use validator_manager::{
list_validators::ListConfig,
move_validators::{MoveConfig, PasswordSource, Validators},
};
use zeroize::Zeroizing;

const EXAMPLE_ETH1_ADDRESS: &str = "0x00000000219ab540356cBB839Cbe05303d7705Fa";

Expand Down Expand Up @@ -280,13 +281,77 @@ pub fn validator_import_using_both_file_flags() {
.assert_failed();
}

#[test]
pub fn validator_import_keystore_file_without_password_flag_should_fail() {
CommandLineTest::validators_import()
.flag("--vc-token", Some("./token.json"))
.flag("--keystore-file", Some("./keystore.json"))
.assert_failed();
}

#[test]
pub fn validator_import_keystore_file_with_password_flag_should_pass() {
CommandLineTest::validators_import()
.flag("--vc-token", Some("./token.json"))
.flag("--keystore-file", Some("./keystore.json"))
.flag("--password", Some("abcd"))
.assert_success(|config| {
let expected = ImportConfig {
validators_file_path: None,
keystore_file_path: Some(PathBuf::from("./keystore.json")),
vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(),
vc_token_path: PathBuf::from("./token.json"),
ignore_duplicates: false,
password: Some(Zeroizing::new("abcd".into())),
fee_recipient: None,
builder_boost_factor: None,
gas_limit: None,
builder_proposals: None,
enabled: None,
prefer_builder_proposals: None,
};
assert_eq!(expected, config);
println!("{:?}", expected);
});
}

#[test]
pub fn validator_import_missing_both_file_flags() {
CommandLineTest::validators_import()
.flag("--vc-token", Some("./token.json"))
.assert_failed();
}

#[test]
pub fn validator_import_fee_recipient_override() {
CommandLineTest::validators_import()
.flag("--validators-file", Some("./vals.json"))
.flag("--vc-token", Some("./token.json"))
.flag("--suggested-fee-recipient", Some(EXAMPLE_ETH1_ADDRESS))
.flag("--gas-limit", Some("1337"))
.flag("--builder-proposals", Some("true"))
.flag("--builder-boost-factor", Some("150"))
.flag("--prefer-builder-proposals", Some("true"))
.flag("--enabled", Some("false"))
.assert_success(|config| {
let expected = ImportConfig {
validators_file_path: Some(PathBuf::from("./vals.json")),
keystore_file_path: None,
vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(),
vc_token_path: PathBuf::from("./token.json"),
ignore_duplicates: false,
password: None,
fee_recipient: Some(Address::from_str(EXAMPLE_ETH1_ADDRESS).unwrap()),
builder_boost_factor: Some(150),
gas_limit: Some(1337),
builder_proposals: Some(true),
enabled: Some(false),
prefer_builder_proposals: Some(true),
};
assert_eq!(expected, config);
});
}

#[test]
pub fn validator_move_defaults() {
CommandLineTest::validators_move()
Expand Down
160 changes: 114 additions & 46 deletions validator_manager/src/import_validators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,7 @@ pub fn cli_app() -> Command {
.value_name("ETH1_ADDRESS")
.help("When provided, the imported validator will use the suggested fee recipient. Omit this flag to use the default value from the VC.")
.action(ArgAction::Set)
.display_order(0)
.requires(KEYSTORE_FILE_FLAG),
.display_order(0),
)
.arg(
Arg::new(GAS_LIMIT)
Expand All @@ -122,8 +121,7 @@ pub fn cli_app() -> Command {
.help("When provided, the imported validator will use this gas limit. It is recommended \
to leave this as the default value by not specifying this flag.",)
.action(ArgAction::Set)
.display_order(0)
.requires(KEYSTORE_FILE_FLAG),
.display_order(0),
)
.arg(
Arg::new(BUILDER_PROPOSALS)
Expand All @@ -132,8 +130,7 @@ pub fn cli_app() -> Command {
blocks via builder rather than the local EL.",)
.value_parser(["true","false"])
.action(ArgAction::Set)
.display_order(0)
.requires(KEYSTORE_FILE_FLAG),
.display_order(0),
)
.arg(
Arg::new(BUILDER_BOOST_FACTOR)
Expand All @@ -144,8 +141,7 @@ pub fn cli_app() -> Command {
when choosing between a builder payload header and payload from \
the local execution node.",)
.action(ArgAction::Set)
.display_order(0)
.requires(KEYSTORE_FILE_FLAG),
.display_order(0),
)
.arg(
Arg::new(PREFER_BUILDER_PROPOSALS)
Expand All @@ -154,8 +150,16 @@ pub fn cli_app() -> Command {
constructed by builders, regardless of payload value.",)
.value_parser(["true","false"])
.action(ArgAction::Set)
.display_order(0)
.requires(KEYSTORE_FILE_FLAG),
.display_order(0),
)
.arg(
Arg::new(ENABLED)
.long(ENABLED)
.help("When provided, the imported validator will be \
enabled or disabled.",)
.value_parser(["true","false"])
.action(ArgAction::Set)
.display_order(0),
)
}

Expand Down Expand Up @@ -225,48 +229,112 @@ async fn run(config: ImportConfig) -> Result<(), String> {
enabled,
} = config;

let validators: Vec<ValidatorSpecification> =
if let Some(validators_format_path) = &validators_file_path {
if !validators_format_path.exists() {
return Err(format!(
"Unable to find file at {:?}",
validators_format_path
));
}
let validators: Vec<ValidatorSpecification> = if let Some(validators_format_path) =
&validators_file_path
{
if !validators_format_path.exists() {
return Err(format!(
"Unable to find file at {:?}",
validators_format_path
));
}

let validators_file = fs::OpenOptions::new()
.read(true)
.create(false)
.open(validators_format_path)
.map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?;
let validators_file = fs::OpenOptions::new()
.read(true)
.create(false)
.open(validators_format_path)
.map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?;

serde_json::from_reader(&validators_file).map_err(|e| {
// Define validators as mutable so that if a relevant flag is supplied, the fields can be overridden.
let mut validators: Vec<ValidatorSpecification> = serde_json::from_reader(&validators_file)
.map_err(|e| {
format!(
"Unable to parse JSON in {:?}: {:?}",
validators_format_path, e
)
})?
} else if let Some(keystore_format_path) = &keystore_file_path {
vec![ValidatorSpecification {
voting_keystore: KeystoreJsonStr(
Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?,
),
voting_keystore_password: password.ok_or_else(|| {
"The --password flag is required to supply the keystore password".to_string()
})?,
slashing_protection: None,
fee_recipient,
gas_limit,
builder_proposals,
builder_boost_factor,
prefer_builder_proposals,
enabled,
}]
} else {
return Err(format!(
"One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required."
));
};
})?;

// Log the overridden note when one or more flags is supplied
if !validators.is_empty() {
if let Some(override_fee_recipient) = fee_recipient {
eprintln!(
"Please note! --suggested-fee-recipient is provided. This will override existing fee recipient defined in validators.json with: {:?}",
override_fee_recipient
Copy link
Member

@macladson macladson Jan 7, 2026

Choose a reason for hiding this comment

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

Ah so as a consequence of moving the logs inside the for loop, this will log a separate instance of each Please note! log for every validator in your validator.json file. So we should either:

  1. Add the validator public key to the log line. This makes it clear that each validator will have the field overridden.
  2. Move the logging outside back outside the for loop but add a conditional that it doesn't run when validators.len() == 0.

I think I'm fine with either, it's just a matter of which we feel is more ergonomic (1 log per validator vs 1 log total for all validators). For 100s of validators, 1 log per validator would likely be too much. But if you had 2 validators, having a reminder that the override will apply to both might be nice

Copy link
Member Author

Choose a reason for hiding this comment

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

I am leaning towards no. 2, I will revise

Copy link
Member Author

@chong-he chong-he Jan 7, 2026

Choose a reason for hiding this comment

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

I added the check if !validators.is_empty() (for validators.len() == 0), but I am thinking that this may not be exactly accurate?

For example, when we import new validators and the VC has no validators, then the logs Please note! ... wouldn't be shown, which is not really the intention. i.e., it's not only about modifying existing validators, it can also be used to modify the fields for newly added validators (including the scenario when the VC has no validators)

Copy link
Member Author

Choose a reason for hiding this comment

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

I removed the if !validators.is_empty() condition with reason stated above: 2239b55

Let me know if this is no good and I am happy to revert the change

);
}
if let Some(override_gas_limit) = gas_limit {
eprintln!(
"Please note! --gas-limit is provided. This will override existing gas limit defined in validators.json with: {}",
override_gas_limit
);
}
if let Some(override_builder_proposals) = builder_proposals {
eprintln!(
"Please note! --builder-proposals is provided. This will override existing builder proposal setting defined in validators.json with: {}",
override_builder_proposals
);
}
if let Some(override_builder_boost_factor) = builder_boost_factor {
eprintln!(
"Please note! --builder-boost-factor is provided. This will override existing builder boost factor defined in validators.json with: {}",
override_builder_boost_factor
);
}
if let Some(override_prefer_builder_proposals) = prefer_builder_proposals {
eprintln!(
"Please note! --prefer-builder-proposals is provided. This will override existing prefer builder proposal setting defined in validators.json with: {}",
override_prefer_builder_proposals
);
}
if let Some(override_enabled) = enabled {
eprintln!(
"Please note! --enabled flag is provided. This will override existing setting defined in validators.json with: {}",
override_enabled
);
}
}

// Override the fields in validators.json file if the flag is supplied
for validator in &mut validators {
if let Some(override_fee_recipient) = fee_recipient {
validator.fee_recipient = Some(override_fee_recipient);
Copy link
Member

Choose a reason for hiding this comment

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

We could move the logging inside the for loop? I doubt we need to log anything in the case that validators.len() == 0 and it would consolidate the if lets

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the review. I got what you mean.

Previously, before consolidating the if let, the message would be logged whenever the flags (e.g., -suggested-fee-recipient) are used together with lighthouse vm import, even though there are no validators

Now, after moving in the if loop, when there is no validators, the if loop will not be executed, so no message is logged. Makes complete sense

Revise in 6237ab3

}
if let Some(override_gas_limit) = gas_limit {
validator.gas_limit = Some(override_gas_limit);
}
if let Some(override_builder_proposals) = builder_proposals {
validator.builder_proposals = Some(override_builder_proposals);
}
if let Some(override_builder_boost_factor) = builder_boost_factor {
validator.builder_boost_factor = Some(override_builder_boost_factor);
}
if let Some(override_enabled) = enabled {
validator.enabled = Some(override_enabled);
}
}

validators
} else if let Some(keystore_format_path) = &keystore_file_path {
vec![ValidatorSpecification {
voting_keystore: KeystoreJsonStr(
Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?,
),
voting_keystore_password: password.ok_or_else(|| {
"The --password flag is required to supply the keystore password".to_string()
})?,
slashing_protection: None,
fee_recipient,
gas_limit,
builder_proposals,
builder_boost_factor,
prefer_builder_proposals,
enabled,
}]
} else {
return Err(format!(
"One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required."
));
};

let count = validators.len();

Expand Down