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
77 changes: 75 additions & 2 deletions bin/ethlambda/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ pub(crate) enum Command {
/// Replay real chain history from `.era` files and check it against the stored states.
ReplayEra(ReplayEraOptions),
/// Run the beacon-chain validator client against a beacon node's HTTP API.
//
// `subcommand_negates_reqs`: `ethlambda validator exit` carries only its
// own flags, so the client's required ones must not be demanded of it;
// `args_conflicts_with_subcommands`: and none of the client's flags may be
// given alongside it, since none applies. On the variant rather than on
// `ValidatorOptions`, where clap's derive does not pick them up.
#[command(subcommand_negates_reqs = true, args_conflicts_with_subcommands = true)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here: with the exit as its own top-level command, neither setting is needed and Validator goes back to a plain variant.

Validator(crate::validator::ValidatorOptions),
/// Import or export the validator client's EIP-3076 slashing-protection
/// history, offline.
Expand Down Expand Up @@ -203,8 +210,74 @@ mod tests {
Command::Validator(options) => options,
other => panic!("expected a validator invocation, got {other:?}"),
};
assert_eq!(options.beacon_nodes, vec!["http://localhost:5052"]);
assert_eq!(options.validators_dir, PathBuf::from("/tmp/validators"));
assert_eq!(
options.beacon_nodes.as_deref(),
Some(&["http://localhost:5052".to_string()][..])
);
assert_eq!(
options.validators_dir,
Some(PathBuf::from("/tmp/validators"))
);
assert!(options.command.is_none());
}

/// Without the sub-command the client's flags stay required: a bare
/// `ethlambda validator` must not start a client with nothing configured.
#[test]
fn a_client_run_still_requires_its_flags() {
let args = ["ethlambda", "validator", "--graffiti", "hello"];
assert!(try_parse_from(args.iter().map(OsString::from)).is_err());
}

/// `validator exit` carries only its own flags: the client's required
/// ones are not demanded of it.
#[test]
fn the_validator_exit_subcommand_parses_without_the_client_flags() {
let args = [
"ethlambda",
"validator",
"exit",
"--keystore",
"/tmp/keystore.json",
"--password-file",
"/tmp/password",
"--beacon-node",
"http://localhost:5052",
"--presign",
"--epoch",
"123",
];
let command = try_parse_from(args.iter().map(OsString::from)).expect("invocation parses");
let Command::Validator(options) = command else {
panic!("expected a validator invocation, got {command:?}");
};
let Some(crate::validator::ValidatorCommand::Exit(exit)) = options.command else {
panic!("expected the exit sub-command, got {:?}", options.command);
};
assert_eq!(exit.keystore, PathBuf::from("/tmp/keystore.json"));
assert_eq!(exit.password_file, PathBuf::from("/tmp/password"));
assert_eq!(exit.beacon_node, "http://localhost:5052");
assert_eq!(exit.epoch, Some(123));
assert!(exit.presign);
assert!(!exit.no_confirmation);
assert!(!exit.no_wait);
}

/// And the client's own flags are refused beside it, since none applies.
#[test]
fn the_client_flags_are_refused_beside_validator_exit() {
let args = [
"ethlambda",
"validator",
"--graffiti",
"hello",
"exit",
"--keystore",
"/tmp/keystore.json",
"--password-file",
"/tmp/password",
];
assert!(try_parse_from(args.iter().map(OsString::from)).is_err());
}

/// The historical flat form must keep working, unchanged.
Expand Down
16 changes: 12 additions & 4 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,18 @@ fn main() -> eyre::Result<()> {
init_info_stderr_logging()?;
era::run(options)
}
Command::Validator(options) => {
init_node_logging()?;
validator::run(options)
}
Command::Validator(mut options) => match options.command.take() {
// A one-shot command whose stdout may carry a pre-signed exit, so
// its logs go to stderr like keygen's.
Some(validator::ValidatorCommand::Exit(exit)) => {
init_info_stderr_logging()?;
validator::exit(exit)
}
None => {
init_node_logging()?;
validator::run(options)
}
},
// Offline file work: no runtime, and stdout left alone.
Command::SlashingProtection(options) => {
init_info_stderr_logging()?;
Expand Down
149 changes: 130 additions & 19 deletions bin/ethlambda/src/validator.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,51 @@
//! `ethlambda validator`: the beacon-chain validator client.
//! `ethlambda validator`: the beacon-chain validator client, and
//! `ethlambda validator exit`, which signs and publishes one validator's
//! voluntary exit.
//!
//! It talks to a beacon node over the standard REST Beacon API and depends on
//! nothing else in this binary, so it runs against any conformant node.
//! Both talk to a beacon node over the standard REST Beacon API and depend on
//! nothing else in this binary, so they run against any conformant node.

use std::path::PathBuf;

use ethlambda_types::beacon::primitives::{Bytes32, ExecutionAddress, H160};
use ethlambda_validator::ValidatorConfig;
use ethlambda_validator::exit::{self, CONFIRMATION_PHRASE, ExitConfig, Outcome};
use ethlambda_validator::proposer_settings::{GRAFFITI_BYTES, graffiti_from_text};

/// An execution address is exactly this wide.
const ADDRESS_BYTES: usize = 20;

// The three required flags are `Option`s that clap still marks `required`.
// `subcommand_negates_reqs` on `Command::Validator` lets `validator exit` omit
// them, and clap's derive builds this struct for that invocation too, which a
// plain `PathBuf` field refuses with its own "required argument was not
// provided". Clap still refuses a client run without them, so `None` never
// reaches [`run`]; [`ValidatorOptions::required`] says so rather than unwrap.
#[derive(Debug, clap::Args)]
pub(crate) struct ValidatorOptions {
/// `None` runs the client. See `Command::Validator` for why the client's
/// required flags are not demanded of, nor accepted beside, a sub-command.
#[command(subcommand)]
pub(crate) command: Option<ValidatorCommand>,

/// Base URL(s) of the beacon node's HTTP API (e.g. http://localhost:5052).
///
/// Several may be supplied, comma-separated or by repeating the flag. They
/// are tried in order and the first that answers is used, so the order is
/// a preference, not a load-balancing policy.
#[arg(long, value_delimiter = ',', required = true)]
pub(crate) beacon_nodes: Vec<String>,
pub(crate) beacon_nodes: Option<Vec<String>>,

/// Directory holding the EIP-2335 keystores and `validator_definitions.yml`.
/// Without that file, keystores in the Lighthouse layout
/// (`<0xpubkey>/voting-keystore.json`, password in
/// `--secrets-dir/<0xpubkey>`) are discovered and the file is written.
#[arg(long)]
pub(crate) validators_dir: PathBuf,
#[arg(long, required = true)]
pub(crate) validators_dir: Option<PathBuf>,

/// Directory holding one password file per keystore.
#[arg(long)]
pub(crate) secrets_dir: PathBuf,
#[arg(long, required = true)]
pub(crate) secrets_dir: Option<PathBuf>,
Comment on lines 36 to +48

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please don't make these Options while keeping them required = true. I don't want flags that are optional in the type but required by clap.

Changing the type, then required() re-checking it at runtime, then subcommand_negates_reqs/args_conflicts_with_subcommands on the variant: all three are workarounds for fitting exit inside ValidatorOptions. Every future required client flag would have to repeat the pattern, and one that misses it fails with a runtime eyre error instead of a clap usage error.

Keep beacon_nodes, validators_dir and secrets_dir as plain Vec<String> / PathBuf, and make the exit its own top-level command, the way slashing-protection already is (e.g. ethlambda voluntary-exit ...). That keeps ethlambda validator --beacon-nodes ... exactly as it is today and removes all three workarounds.


/// Bind address for the metrics and keymanager servers.
#[arg(long, default_value = "127.0.0.1")]
Expand Down Expand Up @@ -93,7 +107,66 @@ pub(crate) struct ValidatorOptions {
pub(crate) init_slashing_protection: bool,
}

/// What `ethlambda validator` can do besides run as a client.
#[derive(Debug, clap::Subcommand)]
pub(crate) enum ValidatorCommand {
/// Sign a voluntary exit for one validator and publish it to a beacon node.
///
/// Irreversible once the chain processes it. Asks for a confirmation phrase
/// before publishing unless --no-confirmation is given.
Exit(ExitOptions),
}

#[derive(Debug, clap::Args)]
pub(crate) struct ExitOptions {
/// Path to the validator's EIP-2335 voting keystore.
#[arg(long)]
pub(crate) keystore: PathBuf,

/// Path to the file holding the keystore's password.
#[arg(long)]
pub(crate) password_file: PathBuf,

/// Base URL of the beacon node's HTTP API.
#[arg(long, default_value = "http://localhost:5052")]
pub(crate) beacon_node: String,

/// The exit's epoch, before which the chain refuses it. Defaults to the
/// current one. A later epoch needs --presign, since a node refuses an
/// exit whose epoch has not come.
#[arg(long)]
pub(crate) epoch: Option<u64>,

/// Sign the exit and print it as JSON on stdout without publishing it.
#[arg(long, default_value = "false")]
pub(crate) presign: bool,

/// Publish without asking for the confirmation phrase. Use with care.
#[arg(long, default_value = "false")]
pub(crate) no_confirmation: bool,

/// Return as soon as the beacon node has accepted the exit, without
/// waiting for the chain to process it.
#[arg(long, default_value = "false")]
pub(crate) no_wait: bool,
}

impl ValidatorOptions {
/// The flags clap requires of a client run, which only `validator exit`
/// may omit (see the struct's comment). A `None` here is a bug in the
/// command-line wiring, reported as such rather than unwrapped.
fn required(self) -> eyre::Result<(Vec<String>, PathBuf, PathBuf)> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Goes away with the comment above: once the fields are plain required ones, clap guarantees them and there's nothing left to re-check at runtime.

match (self.beacon_nodes, self.validators_dir, self.secrets_dir) {
(Some(beacon_nodes), Some(validators_dir), Some(secrets_dir)) => {
Ok((beacon_nodes, validators_dir, secrets_dir))
}
_ => eyre::bail!(
"--beacon-nodes, --validators-dir and --secrets-dir are required to run the \
validator client"
),
}
}

/// The graffiti bytes, or an error naming the limit if the text is too
/// long.
///
Expand Down Expand Up @@ -157,31 +230,69 @@ pub(crate) async fn run(options: ValidatorOptions) -> eyre::Result<()> {
options.validate_ports()?;
let graffiti = options.graffiti()?;
let suggested_fee_recipient = options.suggested_fee_recipient()?;
let metrics = std::net::SocketAddr::new(options.http_address, options.metrics_port);
let keymanager = options
.enable_keymanager
.then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port));
let init_slashing_protection = options.init_slashing_protection;
let (beacon_nodes, validators_dir, secrets_dir) = options.required()?;
ethlambda_validator::run(ValidatorConfig {
beacon_nodes: options.beacon_nodes,
beacon_nodes,
graffiti,
suggested_fee_recipient,
validators_dir: options.validators_dir,
secrets_dir: options.secrets_dir,
metrics: std::net::SocketAddr::new(options.http_address, options.metrics_port),
keymanager: options
.enable_keymanager
.then(|| std::net::SocketAddr::new(options.http_address, options.keymanager_port)),
init_slashing_protection: options.init_slashing_protection,
validators_dir,
secrets_dir,
metrics,
keymanager,
init_slashing_protection,
})
.await
.map_err(Into::into)
}

/// Sign and publish one validator's voluntary exit, on its own runtime like
/// [`run`].
///
/// A run that did not get the confirmation phrase ends with a failing status.
/// Nothing went wrong, but nothing was published either, and that is what a
/// script reading the status asked about: one run without `--no-confirmation`
/// and with nobody at stdin reads an empty line, and must not look like an
/// exit that went out.
#[tokio::main]
pub(crate) async fn exit(options: ExitOptions) -> eyre::Result<()> {
let outcome = exit::run(ExitConfig {
keystore: options.keystore,
password_file: options.password_file,
beacon_node: options.beacon_node,
options: exit::ExitOptions {
epoch: options.epoch,
presign: options.presign,
no_confirmation: options.no_confirmation,
no_wait: options.no_wait,
},
})
.await?;
match outcome {
Outcome::Presigned(signed) => exit::print_presigned(&signed)?,
Outcome::NotConfirmed => eyre::bail!(
"the voluntary exit was not published: the confirmation phrase is \
\"{CONFIRMATION_PHRASE}\""
),
Outcome::Published { .. } => {}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

fn options(graffiti: &str) -> ValidatorOptions {
ValidatorOptions {
beacon_nodes: vec!["http://localhost:5052".to_string()],
validators_dir: PathBuf::from("/tmp/validators"),
secrets_dir: PathBuf::from("/tmp/secrets"),
command: None,
beacon_nodes: Some(vec!["http://localhost:5052".to_string()]),
validators_dir: Some(PathBuf::from("/tmp/validators")),
secrets_dir: Some(PathBuf::from("/tmp/secrets")),
http_address: "127.0.0.1".parse().expect("valid address"),
keymanager_port: 5062,
metrics_port: 5064,
Expand Down
Loading