Add initial progress

This commit is contained in:
Paul Hauner
2022-08-16 15:48:14 +10:00
parent 66e24e27f3
commit 1984c6bcbe
13 changed files with 605 additions and 52 deletions

View File

@@ -0,0 +1,33 @@
use clap::App;
use clap::ArgMatches;
use environment::Environment;
use types::EthSpec;
mod validator;
pub const CMD: &str = "validator_manager";
pub fn cli_app<'a, 'b>() -> App<'a, 'b> {
App::new(CMD)
.visible_aliases(&["vm", CMD])
.about("Utilities for managing a Lighthouse validator client via the HTTP API.")
.subcommand(validator::cli_app())
}
/// Run the account manager, returning an error if the operation did not succeed.
pub async fn run<'a, T: EthSpec>(
matches: &'a ArgMatches<'a>,
env: Environment<T>,
) -> Result<(), String> {
match matches.subcommand() {
(validator::CMD, Some(matches)) => validator::cli_run(matches, env).await?,
(unknown, _) => {
return Err(format!(
"{} is not a valid {} command. See --help.",
unknown, CMD
));
}
}
Ok(())
}

View File

@@ -0,0 +1,392 @@
use account_utils::{random_password, read_mnemonic_from_cli, read_password};
use clap::{App, Arg, ArgMatches};
use environment::Environment;
use eth2::{lighthouse_vc::http_client::ValidatorClientHttpClient, SensitiveUrl};
use eth2_wallet::WalletBuilder;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use tree_hash::TreeHash;
use types::*;
pub const CMD: &str = "create";
pub const DEPOSIT_GWEI_FLAG: &str = "deposit-gwei";
pub const JSON_DEPOSIT_DATA_PATH: &str = "json-deposit-data-path";
pub const COUNT_FLAG: &str = "count";
pub const STDIN_INPUTS_FLAG: &str = "stdin-inputs";
pub const FIRST_INDEX_FLAG: &str = "first-index";
pub const MNEMONIC_FLAG: &str = "mnemonic-path";
pub const PASSWORD_FLAG: &str = "password-file";
pub const ETH1_WITHDRAWAL_ADDRESS_FLAG: &str = "eth1-withdrawal-address";
pub const DRY_RUN_FLAG: &str = "dry-run";
pub const VALIDATOR_CLIENT_URL_FLAG: &str = "validator-client-url";
pub const VALIDATOR_CLIENT_TOKEN_FLAG: &str = "validator-client-token";
pub const IGNORE_DUPLICATES_FLAG: &str = "ignore-duplicates";
pub const KEYSTORE_UPLOAD_BATCH_SIZE: &str = "keystore-upload-batch-size";
/// The structure generated by the `staking-deposit-cli` which has become a quasi-standard for
/// browser-based deposit submission tools (e.g., the Ethereum Launchpad and Lido).
///
/// We assume this code as the canonical definition:
///
/// https://github.com/ethereum/staking-deposit-cli/blob/76ed78224fdfe3daca788d12442b3d1a37978296/staking_deposit/credentials.py#L131-L144
#[derive(Debug, PartialEq, Serialize)]
pub struct StandardDepositDataJson {
pub pubkey: PublicKeyBytes,
pub withdrawal_credentials: Hash256,
#[serde(with = "eth2_serde_utils::quoted_u64")]
pub amount: u64,
pub signature: SignatureBytes,
#[serde(with = "eth2_serde_utils::bytes_4_hex")]
pub fork_version: [u8; 4],
pub eth2_network_name: String,
pub deposit_message_root: Hash256,
pub deposit_data_root: Hash256,
}
impl StandardDepositDataJson {
fn new(
keypair: &Keypair,
withdrawal_credentials: Hash256,
amount: u64,
spec: &ChainSpec,
) -> Result<Self, String> {
let deposit_data = {
let mut deposit_data = DepositData {
pubkey: keypair.pk.clone().into(),
withdrawal_credentials,
amount,
signature: SignatureBytes::empty(),
};
deposit_data.signature = deposit_data.create_signature(&keypair.sk, spec);
deposit_data
};
let domain = spec.get_deposit_domain();
let deposit_message_root = deposit_data.as_deposit_message().signing_root(domain);
let deposit_data_root = deposit_data.tree_hash_root();
let DepositData {
pubkey,
withdrawal_credentials,
amount,
signature,
} = deposit_data;
Ok(Self {
pubkey,
withdrawal_credentials,
amount,
signature,
fork_version: spec.genesis_fork_version,
eth2_network_name: spec
.config_name
.clone()
.ok_or("The network specification does not have a CONFIG_NAME set")?,
deposit_message_root,
deposit_data_root,
})
}
}
pub fn cli_app<'a, 'b>() -> App<'a, 'b> {
App::new(CMD)
.about("Creates new validators from BIP-39 mnemonic.")
.arg(
Arg::with_name(DEPOSIT_GWEI_FLAG)
.long(DEPOSIT_GWEI_FLAG)
.value_name("DEPOSIT_GWEI")
.help(
"The GWEI value of the deposit amount. Defaults to the minimum amount \
required for an active validator (MAX_EFFECTIVE_BALANCE)",
)
.takes_value(true),
)
.arg(
Arg::with_name(FIRST_INDEX_FLAG)
.long(FIRST_INDEX_FLAG)
.value_name("FIRST_INDEX")
.help("The first of consecutive key indexes you wish to recover.")
.takes_value(true)
.required(false)
.default_value("0"),
)
.arg(
Arg::with_name(COUNT_FLAG)
.long(COUNT_FLAG)
.value_name("VALIDATOR_COUNT")
.help("The number of validators to create, regardless of how many already exist")
.conflicts_with("at-most")
.takes_value(true),
)
.arg(
Arg::with_name(MNEMONIC_FLAG)
.long(MNEMONIC_FLAG)
.value_name("MNEMONIC_PATH")
.help("If present, the mnemonic will be read in from this file.")
.takes_value(true),
)
.arg(
Arg::with_name(STDIN_INPUTS_FLAG)
.takes_value(false)
.hidden(cfg!(windows))
.long(STDIN_INPUTS_FLAG)
.help("If present, read all user inputs from stdin instead of tty."),
)
.arg(
Arg::with_name(JSON_DEPOSIT_DATA_PATH)
.long(JSON_DEPOSIT_DATA_PATH)
.value_name("PATH")
.help(
"When provided, outputs a JSON file containing deposit data which \
is equivalent to the 'deposit-data-*.json' file used by the \
staking-deposit-cli tool.",
)
.takes_value(true),
)
.arg(
Arg::with_name(PASSWORD_FLAG)
.long(PASSWORD_FLAG)
.value_name("STRING")
.help(
"A path to a file containing the password which will unlock the wallet. \
If the file does not exist, a random password will be generated and \
saved at that path. To avoid confusion, if the file does not already \
exist it must include a '.pass' suffix.",
)
.takes_value(true),
)
.arg(
Arg::with_name(ETH1_WITHDRAWAL_ADDRESS_FLAG)
.long(ETH1_WITHDRAWAL_ADDRESS_FLAG)
.value_name("ETH1_ADDRESS")
.help(
"If this field is set, the given eth1 address will be used to create the \
withdrawal credentials. Otherwise, it will generate withdrawal credentials \
with the mnemonic-derived withdrawal public key in EIP-2334 format.",
)
.takes_value(true),
)
.arg(
Arg::with_name(DRY_RUN_FLAG)
.takes_value(false)
.long(DRY_RUN_FLAG)
.help(
"If present, perform all actions without ever contacting the validator client.",
),
)
.arg(
Arg::with_name(VALIDATOR_CLIENT_URL_FLAG)
.long(VALIDATOR_CLIENT_URL_FLAG)
.value_name("HTTP_ADDRESS")
.help("A HTTP(S) address of a validator client using the keymanager-API.")
.required_unless(DRY_RUN_FLAG)
.takes_value(true),
)
.arg(
Arg::with_name(VALIDATOR_CLIENT_TOKEN_FLAG)
.long(VALIDATOR_CLIENT_TOKEN_FLAG)
.value_name("PATH")
.help("The file containing a token required by the validator client.")
.required_unless(DRY_RUN_FLAG)
.takes_value(true),
)
.arg(
Arg::with_name(IGNORE_DUPLICATES_FLAG)
.takes_value(false)
.long(IGNORE_DUPLICATES_FLAG)
.help(
"If present, ignore any validators which already exist on the VC. \
Without this flag, the process will terminate without making any changes. \
This flag should be used with caution, whilst it does not directly cause \
slashable conditions, it might be an indicator that something is amiss. \
Users should also be careful to avoid submitting duplicate deposits for \
validators that already exist on the VC.",
),
)
.arg(
Arg::with_name(KEYSTORE_UPLOAD_BATCH_SIZE)
.long(KEYSTORE_UPLOAD_BATCH_SIZE)
.value_name("INTEGER")
.help("The number of keystores to be submitted to the VC per request.")
.takes_value(true)
.default_value("16"),
)
}
pub async fn cli_run<'a, T: EthSpec>(
matches: &'a ArgMatches<'a>,
mut env: Environment<T>,
) -> Result<(), String> {
let spec = env.core_context().eth2_config.spec;
let deposit_gwei = clap_utils::parse_optional(matches, DEPOSIT_GWEI_FLAG)?
.unwrap_or(spec.max_effective_balance);
let first_index: u32 = clap_utils::parse_required(matches, FIRST_INDEX_FLAG)?;
let count: u32 = clap_utils::parse_required(matches, COUNT_FLAG)?;
let mnemonic_path: Option<PathBuf> = clap_utils::parse_optional(matches, MNEMONIC_FLAG)?;
let stdin_inputs = cfg!(windows) || matches.is_present(STDIN_INPUTS_FLAG);
let json_deposit_data_path: Option<PathBuf> =
clap_utils::parse_optional(matches, JSON_DEPOSIT_DATA_PATH)?;
let wallet_password_path: Option<PathBuf> = clap_utils::parse_optional(matches, PASSWORD_FLAG)?;
let eth1_withdrawal_address: Option<Address> =
clap_utils::parse_optional(matches, ETH1_WITHDRAWAL_ADDRESS_FLAG)?;
let dry_run = matches.is_present(DRY_RUN_FLAG);
let vc_url: Option<SensitiveUrl> =
clap_utils::parse_optional(matches, VALIDATOR_CLIENT_URL_FLAG)?;
let vc_token_path: Option<PathBuf> =
clap_utils::parse_optional(matches, VALIDATOR_CLIENT_TOKEN_FLAG)?;
let ignore_duplicates = matches.is_present(IGNORE_DUPLICATES_FLAG);
let keystore_upload_batch_size: usize =
clap_utils::parse_required(matches, KEYSTORE_UPLOAD_BATCH_SIZE)?;
let http_client = match (dry_run, vc_url, vc_token_path) {
(false, Some(vc_url), Some(vc_token_path)) => {
let token_bytes = fs::read(&vc_token_path)
.map_err(|e| format!("Failed to read {:?}: {:?}", vc_token_path, e))?;
let token_string = String::from_utf8(token_bytes)
.map_err(|e| format!("Failed to parse {:?} as utf8: {:?}", vc_token_path, e))?;
let http_client = ValidatorClientHttpClient::new(vc_url.clone(), token_string)
.map_err(|e| {
format!(
"Could not instantiate HTTP client from URL and secret: {:?}",
e
)
})?;
// Perform a request to check that the connection works
let remote_keystores = http_client
.get_keystores()
.await
.map_err(|e| format!("Failed to list keystores on VC: {:?}", e))?;
eprintln!(
"Validator client is reachable at {} and reports {} validators",
vc_url,
remote_keystores.data.len()
);
Some(http_client)
}
(true, None, None) => None,
_ => {
return Err(format!(
"Inconsistent use of {}, {} and {} flags",
DRY_RUN_FLAG, VALIDATOR_CLIENT_URL_FLAG, VALIDATOR_CLIENT_TOKEN_FLAG
))
}
};
let mnemonic = read_mnemonic_from_cli(mnemonic_path, stdin_inputs)?;
// A random password is always appropriate for the wallet since it is ephemeral.
let wallet_password = random_password();
let voting_keystore_password = if let Some(path) = wallet_password_path {
read_password(&path)
.map_err(|e| format!("Failed to read password from {:?}: {:?}", path, e))?
} else {
random_password()
};
// A random password is always appropriate for the withdrawal keystore since we don't ever store
// it anywhere.
let withdrawal_keystore_password = random_password();
let mut wallet =
WalletBuilder::from_mnemonic(&mnemonic, wallet_password.as_bytes(), "".to_string())
.map_err(|e| format!("Unable create seed from mnemonic: {:?}", e))?
.build()
.map_err(|e| format!("Unable to create wallet: {:?}", e))?;
wallet
.set_nextaccount(first_index)
.map_err(|e| format!("Failure to set --{}: {:?}", FIRST_INDEX_FLAG, e))?;
let mut voting_keystores = Vec::with_capacity(count as usize);
let mut json_deposits = Some(vec![]).filter(|_| json_deposit_data_path.is_some());
eprintln!("Starting key generation. Each validator may take several seconds.");
for i in 0..count {
let keystores = wallet
.next_validator(
wallet_password.as_bytes(),
voting_keystore_password.as_bytes(),
withdrawal_keystore_password.as_bytes(),
)
.map_err(|e| format!("Failed to derive keystore {}: {:?}", i, e))?;
let voting_keystore = keystores.voting;
let voting_keypair = voting_keystore
.decrypt_keypair(voting_keystore_password.as_bytes())
.map_err(|e| format!("Failed to decrypt voting keystore {}: {:?}", i, e))?;
let voting_pubkey_bytes = voting_keypair.pk.clone().into();
// Check to see if this validator already exists in the VC.
if let Some(http_client) = &http_client {
let remote_keystores = http_client
.get_keystores()
.await
.map_err(|e| format!("Failed to list keystores on VC: {:?}", e))?;
if remote_keystores
.data
.iter()
.find(|keystore| keystore.validating_pubkey == voting_pubkey_bytes)
.is_some()
{
if ignore_duplicates {
eprintln!(
"Validator {:?} already exists in the VC, be cautious of submitting \
duplicate deposits",
IGNORE_DUPLICATES_FLAG
);
} else {
return Err(format!(
"Duplicate validator {:?} detected, see --{} for more information",
voting_keypair.pk, IGNORE_DUPLICATES_FLAG
));
}
}
}
let withdrawal_credentials = if let Some(eth1_withdrawal_address) = eth1_withdrawal_address
{
WithdrawalCredentials::eth1(eth1_withdrawal_address, &spec)
} else {
let withdrawal_keypair = keystores
.withdrawal
.decrypt_keypair(withdrawal_keystore_password.as_bytes())
.map_err(|e| format!("Failed to decrypt withdrawal keystore {}: {:?}", i, e))?;
WithdrawalCredentials::bls(&withdrawal_keypair.pk, &spec)
};
if let Some(json_deposits) = &mut json_deposits {
let json_deposit = StandardDepositDataJson::new(
&voting_keypair,
withdrawal_credentials.into(),
deposit_gwei,
&spec,
)?;
json_deposits.push(json_deposit);
}
eprintln!(
"{}/{}: {:?}",
i.saturating_add(1),
count,
&voting_keypair.pk
);
voting_keystores.push(voting_keystore);
}
eprintln!(
"Generated {} keystores. Starting to submit keystores to VC, \
each keystore may take several seconds",
count
);
for voting_keystore_chunk in voting_keystores.chunks(keystore_upload_batch_size) {
todo!("submit to VC")
}
todo!();
}

View File

@@ -0,0 +1,26 @@
pub mod create;
use clap::{App, ArgMatches};
use environment::Environment;
use types::EthSpec;
pub const CMD: &str = "validator";
pub fn cli_app<'a, 'b>() -> App<'a, 'b> {
App::new(CMD)
.about("Provides commands for managing validators in a Lighthouse Validator Client.")
.subcommand(create::cli_app())
}
pub async fn cli_run<'a, T: EthSpec>(
matches: &'a ArgMatches<'a>,
env: Environment<T>,
) -> Result<(), String> {
match matches.subcommand() {
(create::CMD, Some(matches)) => create::cli_run::<T>(matches, env).await,
(unknown, _) => Err(format!(
"{} does not have a {} command. See --help",
CMD, unknown
)),
}
}