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
38 changes: 38 additions & 0 deletions bin/ethlambda/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const EXPLICIT: &[&str] = &[
BENCHMARK,
REPLAY_ERA,
VALIDATOR,
VOLUNTARY_EXIT,
SLASHING_PROTECTION,
KEYGEN,
"help",
Expand All @@ -44,6 +45,7 @@ const BEACON: &str = "beacon";
const BENCHMARK: &str = "benchmark";
const REPLAY_ERA: &str = "replay-era";
const VALIDATOR: &str = "validator";
const VOLUNTARY_EXIT: &str = "voluntary-exit";
const SLASHING_PROTECTION: &str = "slashing-protection";
const KEYGEN: &str = "keygen";

Expand Down Expand Up @@ -86,6 +88,11 @@ pub(crate) enum Command {
ReplayEra(ReplayEraOptions),
/// Run the beacon-chain validator client against a beacon node's HTTP API.
Validator(crate::validator::ValidatorOptions),
/// 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.
VoluntaryExit(crate::voluntary_exit::VoluntaryExitOptions),
/// Import or export the validator client's EIP-3076 slashing-protection
/// history, offline.
SlashingProtection(crate::slashing_protection::SlashingProtectionOptions),
Expand Down Expand Up @@ -207,6 +214,36 @@ mod tests {
assert_eq!(options.validators_dir, PathBuf::from("/tmp/validators"));
}

/// `voluntary-exit` is a command of its own, so it takes only its own
/// flags and the validator client's required ones are not asked of it.
#[test]
fn voluntary_exit_parses_as_its_own_sub_command() {
let args = [
"ethlambda",
VOLUNTARY_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::VoluntaryExit(exit) = command else {
panic!("expected a voluntary-exit invocation, got {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);
}

/// The historical flat form must keep working, unchanged.
#[test]
fn the_flat_node_form_still_parses_after_adding_validator() {
Expand Down Expand Up @@ -425,6 +462,7 @@ mod tests {
BEACON,
BENCHMARK,
VALIDATOR,
VOLUNTARY_EXIT,
SLASHING_PROTECTION,
KEYGEN,
] {
Expand Down
7 changes: 7 additions & 0 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod network;
mod slashing_protection;
mod validator;
mod version;
mod voluntary_exit;

// Jemalloc causes programs to deadlock during process startup under Shadow.
// See https://github.com/shadow/shadow/issues/3763. Build the Shadow binary
Expand Down Expand Up @@ -110,6 +111,12 @@ fn main() -> eyre::Result<()> {
init_node_logging()?;
validator::run(options)
}
// A one-shot command whose stdout may carry a pre-signed exit, so its
// logs go to stderr like keygen's.
Command::VoluntaryExit(options) => {
init_info_stderr_logging()?;
voluntary_exit::run(options)
}
// Offline file work: no runtime, and stdout left alone.
Command::SlashingProtection(options) => {
init_info_stderr_logging()?;
Expand Down
79 changes: 79 additions & 0 deletions bin/ethlambda/src/voluntary_exit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! `ethlambda voluntary-exit`: sign one validator's voluntary exit and hand it
//! to a beacon node.
//!
//! A command of its own rather than part of `ethlambda validator`: it takes a
//! single keystore and none of the client's flags, and it runs once and ends.
//! Like the client 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.

use std::path::PathBuf;

use ethlambda_validator::exit::{self, CONFIRMATION_PHRASE, ExitConfig, ExitOptions, Outcome};

#[derive(Debug, clap::Args)]
pub(crate) struct VoluntaryExitOptions {
/// 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,
}

/// Sign and publish one validator's voluntary exit, on a runtime of its own:
/// each sub-command picks its concurrency model, and `main` stays synchronous.
///
/// 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 run(options: VoluntaryExitOptions) -> eyre::Result<()> {
let outcome = exit::run(ExitConfig {
keystore: options.keystore,
password_file: options.password_file,
beacon_node: options.beacon_node,
options: 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(())
}
109 changes: 109 additions & 0 deletions crates/net/rpc/src/beacon/validator_client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ use ethlambda_validator::{
},
http::HttpBeaconNode,
},
exit::{ExitOptions, Outcome, run_with as run_exit},
keys::ValidatorStore,
signing::SigningContext,
sync_committee::{SyncCommitteeService, subnets_of},
Expand Down Expand Up @@ -196,6 +197,114 @@ fn keys_for(state: &BeaconState, indices: &[u64]) -> RwLock<ValidatorStore> {
RwLock::new(keys)
}

/// `ethlambda voluntary-exit`, end to end: the client's own lookup of the
/// validator, its signature, and this node's pool endpoint. The fixture's
/// validators are all active since epoch 0 and the wall epoch is far past
/// `SHARD_COMMITTEE_PERIOD`, so the node's gossip rules accept the exit.
#[tokio::test]
async fn the_validator_client_can_exit_a_validator_through_this_node() {
use ethlambda_types::beacon::{
containers::shared::{SignedVoluntaryExit, VoluntaryExit},
operation::BeaconOperation,
};

let (client, state, network) = serve().await;
let pubkey = state.validator(0).unwrap().pubkey;

let details = client
.validator(&pubkey)
.await
.expect("the node answers")
.expect("validator 0 is in the state");
assert_eq!(details.index, 0);
assert_eq!(details.status, "active_ongoing");
assert_eq!(
details.activation_epoch,
state.validator(0).unwrap().activation_epoch
);
assert_eq!(details.exit_epoch, state.validator(0).unwrap().exit_epoch);
assert!(
client
.validator(&BlsPubkey([9; 48]))
.await
.expect("a 404 is an answer, not an error")
.is_none(),
"a key the chain does not know is None"
);

// The whole command, minus the prompt, against this node over the socket.
let keys = {
let mut store = ValidatorStore::new();
store
.insert_secret("test", &secret_key_for(0).to_bytes())
.expect("valid key");
store
};
let options = ExitOptions {
no_confirmation: true,
no_wait: true,
..ExitOptions::default()
};
let mut no_input = std::io::empty();
let mut no_input = std::io::BufReader::new(&mut no_input);
let outcome = run_exit(&client, &keys, pubkey, &options, &mut no_input)
.await
.expect("the node accepts the exit");
assert_eq!(outcome, Outcome::Published { accepted: None });

let published = network.operations.lock().unwrap().clone();
let [BeaconOperation::VoluntaryExit(exit)] = published.as_slice() else {
panic!("expected one published exit, got {published:?}");
};
assert_eq!(exit.message.validator_index, 0);
assert_eq!(exit.message.epoch, compute_epoch_at_slot(state.slot()));

// A refusal comes back as the node's 400 with its reason, not as a
// transport failure.
let forged = SignedVoluntaryExit {
message: VoluntaryExit {
epoch: exit.message.epoch,
validator_index: 1,
},
signature: exit.signature,
};
let err = client
.submit_voluntary_exit(&forged)
.await
.expect_err("validator 0's signature does not cover validator 1's exit");
assert!(
matches!(&err, Error::BeaconNodeStatus { status: 400, body } if body.contains("voluntary exit")),
"got {err:?}"
);

// An exit naming a past epoch is one the chain accepts too: it measures
// the shard committee period against its own epoch and verifies under
// the fork it is at, neither of which the exit's epoch moves.
let pubkey = state.validator(1).unwrap().pubkey;
let keys = {
let mut store = ValidatorStore::new();
store
.insert_secret("test", &secret_key_for(1).to_bytes())
.expect("valid key");
store
};
let options = ExitOptions {
epoch: Some(0),
no_confirmation: true,
no_wait: true,
..ExitOptions::default()
};
run_exit(&client, &keys, pubkey, &options, &mut no_input)
.await
.expect("the node accepts an exit naming epoch 0");
let published = network.operations.lock().unwrap().clone();
let [_, BeaconOperation::VoluntaryExit(exit)] = published.as_slice() else {
panic!("expected a second published exit, got {published:?}");
};
assert_eq!(exit.message.validator_index, 1);
assert_eq!(exit.message.epoch, 0);
}

/// One slot of an attester's work, in the order `ethlambda validator` does it.
///
/// The node has an execution client, since it refuses attestation data
Expand Down
23 changes: 22 additions & 1 deletion crates/validator/src/beacon_node/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
//! | `AttesterDuty` | pubkey, validator_index, committee_index, committee_length, committees_at_slot, validator_committee_index, slot | all seven, all used |
//! | Genesis | genesis_time, genesis_validators_root, genesis_fork_version | the first two; the fork version comes from `/config/spec` |
//! | Syncing | head_slot, sync_distance, is_syncing, is_optimistic, el_offline | is_syncing and is_optimistic gate signing; el_offline is logged |
//! | `ValidatorResponse` | index, balance, status, validator | index, status, validator.pubkey |
//! | `ValidatorResponse` | index, balance, status, validator | index, status, validator.pubkey; the single-validator form also reads the activation, exit and withdrawable epochs |
//! | `ProposerDuty` | pubkey, validator_index, slot | all three, all used |
//! | `ProposerPreparation` | validator_index, fee_recipient | both, sent not read |
//!
Expand Down Expand Up @@ -292,6 +292,27 @@ pub struct ValidatorInnerDto {
pub pubkey: String,
}

/// `GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}`: one
/// `ValidatorResponse`, with the epochs a voluntary exit is checked against.
#[derive(Debug, Clone, Deserialize)]
pub struct ValidatorDetailsDto {
#[serde(with = "quoted_u64")]
pub index: ValidatorIndex,
pub status: String,
pub validator: ValidatorDetailsInnerDto,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ValidatorDetailsInnerDto {
pub pubkey: String,
#[serde(with = "quoted_u64")]
pub activation_epoch: Epoch,
#[serde(with = "quoted_u64")]
pub exit_epoch: Epoch,
#[serde(with = "quoted_u64")]
pub withdrawable_epoch: Epoch,
}

/// What a validator client submits for one attester duty, from Electra on.
#[derive(Debug, Clone, Serialize)]
pub struct SingleAttestationDto {
Expand Down
16 changes: 14 additions & 2 deletions crates/validator/src/beacon_node/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use async_trait::async_trait;
use ethlambda_types::beacon::config::Config;
use ethlambda_types::beacon::containers::altair;
use ethlambda_types::beacon::containers::gloas;
use ethlambda_types::beacon::containers::shared::AttestationData;
use ethlambda_types::beacon::containers::shared::{AttestationData, SignedVoluntaryExit};
use ethlambda_types::beacon::fork::ForkName;
use ethlambda_types::beacon::primitives::{BlsPubkey, Epoch, Root, Slot, ValidatorIndex};
use tracing::{debug, warn};
Expand All @@ -23,7 +23,7 @@ use crate::beacon_node::dto::{
};
use crate::beacon_node::{
AggregateAttestation, AttesterDuties, BeaconNodeApi, BlockRequest, Genesis, ProposerDuties,
PtcDuties, Published, SignedAggregates, ValidatorEntry,
PtcDuties, Published, SignedAggregates, ValidatorDetails, ValidatorEntry,
};
use crate::error::{Error, Result};

Expand Down Expand Up @@ -428,6 +428,18 @@ impl<B: BeaconNodeApi> BeaconNodeApi for FallbackBeaconNode<B> {
})
.await
}

async fn validator(&self, pubkey: &BlsPubkey) -> Result<Option<ValidatorDetails>> {
self.try_each("validator", |node| node.validator(pubkey))
.await
}

async fn submit_voluntary_exit(&self, exit: &SignedVoluntaryExit) -> Result<()> {
self.try_each("submit_voluntary_exit", |node| {
node.submit_voluntary_exit(exit)
})
.await
}
}

#[cfg(test)]
Expand Down
Loading
Loading