## Issue Addressed
Addresses #2557
## Proposed Changes
Adds the `lighthouse validator-manager` command, which provides:
- `lighthouse validator-manager create`
- Creates a `validators.json` file and a `deposits.json` (same format as https://github.com/ethereum/staking-deposit-cli)
- `lighthouse validator-manager import`
- Imports validators from a `validators.json` file to the VC via the HTTP API.
- `lighthouse validator-manager move`
- Moves validators from one VC to the other, utilizing only the VC API.
## Additional Info
In 98bcb947c I've reduced some VC `ERRO` and `CRIT` warnings to `WARN` or `DEBG` for the case where a pubkey is missing from the validator store. These were being triggered when we removed a validator but still had it in caches. It seems to me that `UnknownPubkey` will only happen in the case where we've removed a validator, so downgrading the logs is prudent. All the logs are `DEBG` apart from attestations and blocks which are `WARN`. I thought having *some* logging about this condition might help us down the track.
In 856cd7e37d I've made the VC delete the corresponding password file when it's deleting a keystore. This seemed like nice hygiene. Notably, it'll only delete that password file after it scans the validator definitions and finds that no other validator is also using that password file.
969 lines
36 KiB
Rust
969 lines
36 KiB
Rust
use crate::{
|
|
doppelganger_service::DoppelgangerService,
|
|
http_metrics::metrics,
|
|
initialized_validators::InitializedValidators,
|
|
signing_method::{Error as SigningError, SignableMessage, SigningContext, SigningMethod},
|
|
Config,
|
|
};
|
|
use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition};
|
|
use parking_lot::{Mutex, RwLock};
|
|
use slashing_protection::{
|
|
interchange::Interchange, InterchangeError, NotSafe, Safe, SlashingDatabase,
|
|
};
|
|
use slog::{crit, error, info, warn, Logger};
|
|
use slot_clock::SlotClock;
|
|
use std::iter::FromIterator;
|
|
use std::marker::PhantomData;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use task_executor::TaskExecutor;
|
|
use types::{
|
|
attestation::Error as AttestationError, graffiti::GraffitiString, AbstractExecPayload, Address,
|
|
AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof,
|
|
Domain, Epoch, EthSpec, Fork, Graffiti, Hash256, Keypair, PublicKeyBytes, SelectionProof,
|
|
Signature, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, SignedRoot,
|
|
SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData,
|
|
SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId,
|
|
ValidatorRegistrationData, VoluntaryExit,
|
|
};
|
|
use validator_dir::ValidatorDir;
|
|
|
|
pub use crate::doppelganger_service::DoppelgangerStatus;
|
|
use crate::preparation_service::ProposalData;
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum Error {
|
|
DoppelgangerProtected(PublicKeyBytes),
|
|
UnknownToDoppelgangerService(PublicKeyBytes),
|
|
UnknownPubkey(PublicKeyBytes),
|
|
Slashable(NotSafe),
|
|
SameData,
|
|
GreaterThanCurrentSlot { slot: Slot, current_slot: Slot },
|
|
GreaterThanCurrentEpoch { epoch: Epoch, current_epoch: Epoch },
|
|
UnableToSignAttestation(AttestationError),
|
|
UnableToSign(SigningError),
|
|
}
|
|
|
|
impl From<SigningError> for Error {
|
|
fn from(e: SigningError) -> Self {
|
|
Error::UnableToSign(e)
|
|
}
|
|
}
|
|
|
|
/// Number of epochs of slashing protection history to keep.
|
|
///
|
|
/// This acts as a maximum safe-guard against clock drift.
|
|
const SLASHING_PROTECTION_HISTORY_EPOCHS: u64 = 512;
|
|
|
|
/// Currently used as the default gas limit in execution clients.
|
|
///
|
|
/// https://github.com/ethereum/builder-specs/issues/17
|
|
pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000;
|
|
|
|
struct LocalValidator {
|
|
validator_dir: ValidatorDir,
|
|
voting_keypair: Keypair,
|
|
}
|
|
|
|
/// We derive our own `PartialEq` to avoid doing equality checks between secret keys.
|
|
///
|
|
/// It's nice to avoid secret key comparisons from a security perspective, but it's also a little
|
|
/// risky when it comes to `HashMap` integrity (that's why we need `PartialEq`).
|
|
///
|
|
/// Currently, we obtain keypairs from keystores where we derive the `PublicKey` from a `SecretKey`
|
|
/// via a hash function. In order to have two equal `PublicKey` with different `SecretKey` we would
|
|
/// need to have either:
|
|
///
|
|
/// - A serious upstream integrity error.
|
|
/// - A hash collision.
|
|
///
|
|
/// It seems reasonable to make these two assumptions in order to avoid the equality checks.
|
|
impl PartialEq for LocalValidator {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.validator_dir == other.validator_dir
|
|
&& self.voting_keypair.pk == other.voting_keypair.pk
|
|
}
|
|
}
|
|
|
|
pub struct ValidatorStore<T, E: EthSpec> {
|
|
validators: Arc<RwLock<InitializedValidators>>,
|
|
slashing_protection: SlashingDatabase,
|
|
slashing_protection_last_prune: Arc<Mutex<Epoch>>,
|
|
genesis_validators_root: Hash256,
|
|
spec: Arc<ChainSpec>,
|
|
log: Logger,
|
|
doppelganger_service: Option<Arc<DoppelgangerService>>,
|
|
slot_clock: T,
|
|
fee_recipient_process: Option<Address>,
|
|
gas_limit: Option<u64>,
|
|
builder_proposals: bool,
|
|
task_executor: TaskExecutor,
|
|
_phantom: PhantomData<E>,
|
|
}
|
|
|
|
impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore<T, E> {
|
|
// All arguments are different types. Making the fields `pub` is undesired. A builder seems
|
|
// unnecessary.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
validators: InitializedValidators,
|
|
slashing_protection: SlashingDatabase,
|
|
genesis_validators_root: Hash256,
|
|
spec: ChainSpec,
|
|
doppelganger_service: Option<Arc<DoppelgangerService>>,
|
|
slot_clock: T,
|
|
config: &Config,
|
|
task_executor: TaskExecutor,
|
|
log: Logger,
|
|
) -> Self {
|
|
Self {
|
|
validators: Arc::new(RwLock::new(validators)),
|
|
slashing_protection,
|
|
slashing_protection_last_prune: Arc::new(Mutex::new(Epoch::new(0))),
|
|
genesis_validators_root,
|
|
spec: Arc::new(spec),
|
|
log,
|
|
doppelganger_service,
|
|
slot_clock,
|
|
fee_recipient_process: config.fee_recipient,
|
|
gas_limit: config.gas_limit,
|
|
builder_proposals: config.builder_proposals,
|
|
task_executor,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
|
|
/// Register all local validators in doppelganger protection to try and prevent instances of
|
|
/// duplicate validators operating on the network at the same time.
|
|
///
|
|
/// This function has no effect if doppelganger protection is disabled.
|
|
pub fn register_all_in_doppelganger_protection_if_enabled(&self) -> Result<(), String> {
|
|
if let Some(doppelganger_service) = &self.doppelganger_service {
|
|
for pubkey in self.validators.read().iter_voting_pubkeys() {
|
|
doppelganger_service.register_new_validator::<E, _>(*pubkey, &self.slot_clock)?
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns `true` if doppelganger protection is enabled, or else `false`.
|
|
pub fn doppelganger_protection_enabled(&self) -> bool {
|
|
self.doppelganger_service.is_some()
|
|
}
|
|
|
|
pub fn initialized_validators(&self) -> Arc<RwLock<InitializedValidators>> {
|
|
self.validators.clone()
|
|
}
|
|
|
|
/// Indicates if the `voting_public_key` exists in self and is enabled.
|
|
pub fn has_validator(&self, voting_public_key: &PublicKeyBytes) -> bool {
|
|
self.validators
|
|
.read()
|
|
.validator(voting_public_key)
|
|
.is_some()
|
|
}
|
|
|
|
/// Insert a new validator to `self`, where the validator is represented by an EIP-2335
|
|
/// keystore on the filesystem.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn add_validator_keystore<P: AsRef<Path>>(
|
|
&self,
|
|
voting_keystore_path: P,
|
|
password_storage: PasswordStorage,
|
|
enable: bool,
|
|
graffiti: Option<GraffitiString>,
|
|
suggested_fee_recipient: Option<Address>,
|
|
gas_limit: Option<u64>,
|
|
builder_proposals: Option<bool>,
|
|
) -> Result<ValidatorDefinition, String> {
|
|
let mut validator_def = ValidatorDefinition::new_keystore_with_password(
|
|
voting_keystore_path,
|
|
password_storage,
|
|
graffiti.map(Into::into),
|
|
suggested_fee_recipient,
|
|
gas_limit,
|
|
builder_proposals,
|
|
)
|
|
.map_err(|e| format!("failed to create validator definitions: {:?}", e))?;
|
|
|
|
validator_def.enabled = enable;
|
|
|
|
self.add_validator(validator_def).await
|
|
}
|
|
|
|
/// Insert a new validator to `self`.
|
|
///
|
|
/// This function includes:
|
|
///
|
|
/// - Adding the validator definition to the YAML file, saving it to the filesystem.
|
|
/// - Enabling the validator with the slashing protection database.
|
|
/// - If `enable == true`, starting to perform duties for the validator.
|
|
// FIXME: ignore this clippy lint until the validator store is refactored to use async locks
|
|
#[allow(clippy::await_holding_lock)]
|
|
pub async fn add_validator(
|
|
&self,
|
|
validator_def: ValidatorDefinition,
|
|
) -> Result<ValidatorDefinition, String> {
|
|
let validator_pubkey = validator_def.voting_public_key.compress();
|
|
|
|
self.slashing_protection
|
|
.register_validator(validator_pubkey)
|
|
.map_err(|e| format!("failed to register validator: {:?}", e))?;
|
|
|
|
if let Some(doppelganger_service) = &self.doppelganger_service {
|
|
doppelganger_service
|
|
.register_new_validator::<E, _>(validator_pubkey, &self.slot_clock)?;
|
|
}
|
|
|
|
self.validators
|
|
.write()
|
|
.add_definition_replace_disabled(validator_def.clone())
|
|
.await
|
|
.map_err(|e| format!("Unable to add definition: {:?}", e))?;
|
|
|
|
Ok(validator_def)
|
|
}
|
|
|
|
/// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`.
|
|
/// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`,
|
|
/// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`.
|
|
pub fn proposal_data(&self, pubkey: &PublicKeyBytes) -> Option<ProposalData> {
|
|
self.validators
|
|
.read()
|
|
.validator(pubkey)
|
|
.map(|validator| ProposalData {
|
|
validator_index: validator.get_index(),
|
|
fee_recipient: self
|
|
.get_fee_recipient_defaulting(validator.get_suggested_fee_recipient()),
|
|
gas_limit: self.get_gas_limit_defaulting(validator.get_gas_limit()),
|
|
builder_proposals: self
|
|
.get_builder_proposals_defaulting(validator.get_builder_proposals()),
|
|
})
|
|
}
|
|
|
|
/// Attempts to resolve the pubkey to a validator index.
|
|
///
|
|
/// It may return `None` if the `pubkey` is:
|
|
///
|
|
/// - Unknown.
|
|
/// - Known, but with an unknown index.
|
|
pub fn validator_index(&self, pubkey: &PublicKeyBytes) -> Option<u64> {
|
|
self.validators.read().get_index(pubkey)
|
|
}
|
|
|
|
/// Returns all voting pubkeys for all enabled validators.
|
|
///
|
|
/// The `filter_func` allows for filtering pubkeys based upon their `DoppelgangerStatus`. There
|
|
/// are two primary functions used here:
|
|
///
|
|
/// - `DoppelgangerStatus::only_safe`: only returns pubkeys which have passed doppelganger
|
|
/// protection and are safe-enough to sign messages.
|
|
/// - `DoppelgangerStatus::ignored`: returns all the pubkeys from `only_safe` *plus* those still
|
|
/// undergoing protection. This is useful for collecting duties or other non-signing tasks.
|
|
#[allow(clippy::needless_collect)] // Collect is required to avoid holding a lock.
|
|
pub fn voting_pubkeys<I, F>(&self, filter_func: F) -> I
|
|
where
|
|
I: FromIterator<PublicKeyBytes>,
|
|
F: Fn(DoppelgangerStatus) -> Option<PublicKeyBytes>,
|
|
{
|
|
// Collect all the pubkeys first to avoid interleaving locks on `self.validators` and
|
|
// `self.doppelganger_service()`.
|
|
let pubkeys = self
|
|
.validators
|
|
.read()
|
|
.iter_voting_pubkeys()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
pubkeys
|
|
.into_iter()
|
|
.map(|pubkey| {
|
|
self.doppelganger_service
|
|
.as_ref()
|
|
.map(|doppelganger_service| doppelganger_service.validator_status(pubkey))
|
|
// Allow signing on all pubkeys if doppelganger protection is disabled.
|
|
.unwrap_or_else(|| DoppelgangerStatus::SigningEnabled(pubkey))
|
|
})
|
|
.filter_map(filter_func)
|
|
.collect()
|
|
}
|
|
|
|
/// Returns doppelganger statuses for all enabled validators.
|
|
#[allow(clippy::needless_collect)] // Collect is required to avoid holding a lock.
|
|
pub fn doppelganger_statuses(&self) -> Vec<DoppelgangerStatus> {
|
|
// Collect all the pubkeys first to avoid interleaving locks on `self.validators` and
|
|
// `self.doppelganger_service`.
|
|
let pubkeys = self
|
|
.validators
|
|
.read()
|
|
.iter_voting_pubkeys()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
pubkeys
|
|
.into_iter()
|
|
.map(|pubkey| {
|
|
self.doppelganger_service
|
|
.as_ref()
|
|
.map(|doppelganger_service| doppelganger_service.validator_status(pubkey))
|
|
// Allow signing on all pubkeys if doppelganger protection is disabled.
|
|
.unwrap_or_else(|| DoppelgangerStatus::SigningEnabled(pubkey))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Check if the `validator_pubkey` is permitted by the doppleganger protection to sign
|
|
/// messages.
|
|
pub fn doppelganger_protection_allows_signing(&self, validator_pubkey: PublicKeyBytes) -> bool {
|
|
self.doppelganger_service
|
|
.as_ref()
|
|
// If there's no doppelganger service then we assume it is purposefully disabled and
|
|
// declare that all keys are safe with regard to it.
|
|
.map_or(true, |doppelganger_service| {
|
|
doppelganger_service
|
|
.validator_status(validator_pubkey)
|
|
.only_safe()
|
|
.is_some()
|
|
})
|
|
}
|
|
|
|
pub fn num_voting_validators(&self) -> usize {
|
|
self.validators.read().num_enabled()
|
|
}
|
|
|
|
fn fork(&self, epoch: Epoch) -> Fork {
|
|
self.spec.fork_at_epoch(epoch)
|
|
}
|
|
|
|
/// Returns a `SigningMethod` for `validator_pubkey` *only if* that validator is considered safe
|
|
/// by doppelganger protection.
|
|
fn doppelganger_checked_signing_method(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
) -> Result<Arc<SigningMethod>, Error> {
|
|
if self.doppelganger_protection_allows_signing(validator_pubkey) {
|
|
self.validators
|
|
.read()
|
|
.signing_method(&validator_pubkey)
|
|
.ok_or(Error::UnknownPubkey(validator_pubkey))
|
|
} else {
|
|
Err(Error::DoppelgangerProtected(validator_pubkey))
|
|
}
|
|
}
|
|
|
|
/// Returns a `SigningMethod` for `validator_pubkey` regardless of that validators doppelganger
|
|
/// protection status.
|
|
///
|
|
/// ## Warning
|
|
///
|
|
/// This method should only be used for signing non-slashable messages.
|
|
fn doppelganger_bypassed_signing_method(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
) -> Result<Arc<SigningMethod>, Error> {
|
|
self.validators
|
|
.read()
|
|
.signing_method(&validator_pubkey)
|
|
.ok_or(Error::UnknownPubkey(validator_pubkey))
|
|
}
|
|
|
|
fn signing_context(&self, domain: Domain, signing_epoch: Epoch) -> SigningContext {
|
|
SigningContext {
|
|
domain,
|
|
epoch: signing_epoch,
|
|
fork: self.fork(signing_epoch),
|
|
genesis_validators_root: self.genesis_validators_root,
|
|
}
|
|
}
|
|
|
|
pub async fn randao_reveal(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
signing_epoch: Epoch,
|
|
) -> Result<Signature, Error> {
|
|
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
|
|
let signing_context = self.signing_context(Domain::Randao, signing_epoch);
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::RandaoReveal(signing_epoch),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await?;
|
|
|
|
Ok(signature)
|
|
}
|
|
|
|
pub fn graffiti(&self, validator_pubkey: &PublicKeyBytes) -> Option<Graffiti> {
|
|
self.validators.read().graffiti(validator_pubkey)
|
|
}
|
|
|
|
/// Returns the fee recipient for the given public key. The priority order for fetching
|
|
/// the fee recipient is:
|
|
/// 1. validator_definitions.yml
|
|
/// 2. process level fee recipient
|
|
pub fn get_fee_recipient(&self, validator_pubkey: &PublicKeyBytes) -> Option<Address> {
|
|
// If there is a `suggested_fee_recipient` in the validator definitions yaml
|
|
// file, use that value.
|
|
self.get_fee_recipient_defaulting(self.suggested_fee_recipient(validator_pubkey))
|
|
}
|
|
|
|
pub fn get_fee_recipient_defaulting(&self, fee_recipient: Option<Address>) -> Option<Address> {
|
|
// If there's nothing in the file, try the process-level default value.
|
|
fee_recipient.or(self.fee_recipient_process)
|
|
}
|
|
|
|
/// Returns the suggested_fee_recipient from `validator_definitions.yml` if any.
|
|
/// This has been pulled into a private function so the read lock is dropped easily
|
|
fn suggested_fee_recipient(&self, validator_pubkey: &PublicKeyBytes) -> Option<Address> {
|
|
self.validators
|
|
.read()
|
|
.suggested_fee_recipient(validator_pubkey)
|
|
}
|
|
|
|
/// Returns the gas limit for the given public key. The priority order for fetching
|
|
/// the gas limit is:
|
|
///
|
|
/// 1. validator_definitions.yml
|
|
/// 2. process level gas limit
|
|
/// 3. `DEFAULT_GAS_LIMIT`
|
|
pub fn get_gas_limit(&self, validator_pubkey: &PublicKeyBytes) -> u64 {
|
|
self.get_gas_limit_defaulting(self.validators.read().gas_limit(validator_pubkey))
|
|
}
|
|
|
|
fn get_gas_limit_defaulting(&self, gas_limit: Option<u64>) -> u64 {
|
|
// If there is a `gas_limit` in the validator definitions yaml
|
|
// file, use that value.
|
|
gas_limit
|
|
// If there's nothing in the file, try the process-level default value.
|
|
.or(self.gas_limit)
|
|
// If there's no process-level default, use the `DEFAULT_GAS_LIMIT`.
|
|
.unwrap_or(DEFAULT_GAS_LIMIT)
|
|
}
|
|
|
|
/// Returns a `bool` for the given public key that denotes whther this validator should use the
|
|
/// builder API. The priority order for fetching this value is:
|
|
///
|
|
/// 1. validator_definitions.yml
|
|
/// 2. process level flag
|
|
pub fn get_builder_proposals(&self, validator_pubkey: &PublicKeyBytes) -> bool {
|
|
// If there is a `suggested_fee_recipient` in the validator definitions yaml
|
|
// file, use that value.
|
|
self.get_builder_proposals_defaulting(
|
|
self.validators.read().builder_proposals(validator_pubkey),
|
|
)
|
|
}
|
|
|
|
fn get_builder_proposals_defaulting(&self, builder_proposals: Option<bool>) -> bool {
|
|
builder_proposals
|
|
// If there's nothing in the file, try the process-level default value.
|
|
.unwrap_or(self.builder_proposals)
|
|
}
|
|
|
|
pub async fn sign_block<Payload: AbstractExecPayload<E>>(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
block: BeaconBlock<E, Payload>,
|
|
current_slot: Slot,
|
|
) -> Result<SignedBeaconBlock<E, Payload>, Error> {
|
|
// Make sure the block slot is not higher than the current slot to avoid potential attacks.
|
|
if block.slot() > current_slot {
|
|
warn!(
|
|
self.log,
|
|
"Not signing block with slot greater than current slot";
|
|
"block_slot" => block.slot().as_u64(),
|
|
"current_slot" => current_slot.as_u64()
|
|
);
|
|
return Err(Error::GreaterThanCurrentSlot {
|
|
slot: block.slot(),
|
|
current_slot,
|
|
});
|
|
}
|
|
|
|
let signing_epoch = block.epoch();
|
|
let signing_context = self.signing_context(Domain::BeaconProposer, signing_epoch);
|
|
let domain_hash = signing_context.domain_hash(&self.spec);
|
|
|
|
// Check for slashing conditions.
|
|
let slashing_status = self.slashing_protection.check_and_insert_block_proposal(
|
|
&validator_pubkey,
|
|
&block.block_header(),
|
|
domain_hash,
|
|
);
|
|
|
|
match slashing_status {
|
|
// We can safely sign this block without slashing.
|
|
Ok(Safe::Valid) => {
|
|
metrics::inc_counter_vec(&metrics::SIGNED_BLOCKS_TOTAL, &[metrics::SUCCESS]);
|
|
|
|
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
|
|
let signature = signing_method
|
|
.get_signature::<E, Payload>(
|
|
SignableMessage::BeaconBlock(&block),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await?;
|
|
Ok(SignedBeaconBlock::from_block(block, signature))
|
|
}
|
|
Ok(Safe::SameData) => {
|
|
warn!(
|
|
self.log,
|
|
"Skipping signing of previously signed block";
|
|
);
|
|
metrics::inc_counter_vec(&metrics::SIGNED_BLOCKS_TOTAL, &[metrics::SAME_DATA]);
|
|
Err(Error::SameData)
|
|
}
|
|
Err(NotSafe::UnregisteredValidator(pk)) => {
|
|
warn!(
|
|
self.log,
|
|
"Not signing block for unregistered validator";
|
|
"msg" => "Carefully consider running with --init-slashing-protection (see --help)",
|
|
"public_key" => format!("{:?}", pk)
|
|
);
|
|
metrics::inc_counter_vec(&metrics::SIGNED_BLOCKS_TOTAL, &[metrics::UNREGISTERED]);
|
|
Err(Error::Slashable(NotSafe::UnregisteredValidator(pk)))
|
|
}
|
|
Err(e) => {
|
|
crit!(
|
|
self.log,
|
|
"Not signing slashable block";
|
|
"error" => format!("{:?}", e)
|
|
);
|
|
metrics::inc_counter_vec(&metrics::SIGNED_BLOCKS_TOTAL, &[metrics::SLASHABLE]);
|
|
Err(Error::Slashable(e))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn sign_attestation(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
validator_committee_position: usize,
|
|
attestation: &mut Attestation<E>,
|
|
current_epoch: Epoch,
|
|
) -> Result<(), Error> {
|
|
// Make sure the target epoch is not higher than the current epoch to avoid potential attacks.
|
|
if attestation.data.target.epoch > current_epoch {
|
|
return Err(Error::GreaterThanCurrentEpoch {
|
|
epoch: attestation.data.target.epoch,
|
|
current_epoch,
|
|
});
|
|
}
|
|
|
|
// Checking for slashing conditions.
|
|
let signing_epoch = attestation.data.target.epoch;
|
|
let signing_context = self.signing_context(Domain::BeaconAttester, signing_epoch);
|
|
let domain_hash = signing_context.domain_hash(&self.spec);
|
|
let slashing_status = self.slashing_protection.check_and_insert_attestation(
|
|
&validator_pubkey,
|
|
&attestation.data,
|
|
domain_hash,
|
|
);
|
|
|
|
match slashing_status {
|
|
// We can safely sign this attestation.
|
|
Ok(Safe::Valid) => {
|
|
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::AttestationData(&attestation.data),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await?;
|
|
attestation
|
|
.add_signature(&signature, validator_committee_position)
|
|
.map_err(Error::UnableToSignAttestation)?;
|
|
|
|
metrics::inc_counter_vec(&metrics::SIGNED_ATTESTATIONS_TOTAL, &[metrics::SUCCESS]);
|
|
|
|
Ok(())
|
|
}
|
|
Ok(Safe::SameData) => {
|
|
warn!(
|
|
self.log,
|
|
"Skipping signing of previously signed attestation"
|
|
);
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_ATTESTATIONS_TOTAL,
|
|
&[metrics::SAME_DATA],
|
|
);
|
|
Err(Error::SameData)
|
|
}
|
|
Err(NotSafe::UnregisteredValidator(pk)) => {
|
|
warn!(
|
|
self.log,
|
|
"Not signing attestation for unregistered validator";
|
|
"msg" => "Carefully consider running with --init-slashing-protection (see --help)",
|
|
"public_key" => format!("{:?}", pk)
|
|
);
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_ATTESTATIONS_TOTAL,
|
|
&[metrics::UNREGISTERED],
|
|
);
|
|
Err(Error::Slashable(NotSafe::UnregisteredValidator(pk)))
|
|
}
|
|
Err(e) => {
|
|
crit!(
|
|
self.log,
|
|
"Not signing slashable attestation";
|
|
"attestation" => format!("{:?}", attestation.data),
|
|
"error" => format!("{:?}", e)
|
|
);
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_ATTESTATIONS_TOTAL,
|
|
&[metrics::SLASHABLE],
|
|
);
|
|
Err(Error::Slashable(e))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn sign_voluntary_exit(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
voluntary_exit: VoluntaryExit,
|
|
) -> Result<SignedVoluntaryExit, Error> {
|
|
let signing_epoch = voluntary_exit.epoch;
|
|
let signing_context = self.signing_context(Domain::VoluntaryExit, signing_epoch);
|
|
let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?;
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::VoluntaryExit(&voluntary_exit),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await?;
|
|
|
|
metrics::inc_counter_vec(&metrics::SIGNED_VOLUNTARY_EXITS_TOTAL, &[metrics::SUCCESS]);
|
|
|
|
Ok(SignedVoluntaryExit {
|
|
message: voluntary_exit,
|
|
signature,
|
|
})
|
|
}
|
|
|
|
pub async fn sign_validator_registration_data(
|
|
&self,
|
|
validator_registration_data: ValidatorRegistrationData,
|
|
) -> Result<SignedValidatorRegistrationData, Error> {
|
|
let domain_hash = self.spec.get_builder_domain();
|
|
let signing_root = validator_registration_data.signing_root(domain_hash);
|
|
|
|
let signing_method =
|
|
self.doppelganger_bypassed_signing_method(validator_registration_data.pubkey)?;
|
|
let signature = signing_method
|
|
.get_signature_from_root::<E, BlindedPayload<E>>(
|
|
SignableMessage::ValidatorRegistration(&validator_registration_data),
|
|
signing_root,
|
|
&self.task_executor,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_VALIDATOR_REGISTRATIONS_TOTAL,
|
|
&[metrics::SUCCESS],
|
|
);
|
|
|
|
Ok(SignedValidatorRegistrationData {
|
|
message: validator_registration_data,
|
|
signature,
|
|
})
|
|
}
|
|
|
|
/// Signs an `AggregateAndProof` for a given validator.
|
|
///
|
|
/// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be
|
|
/// modified by actors other than the signing validator.
|
|
pub async fn produce_signed_aggregate_and_proof(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
aggregator_index: u64,
|
|
aggregate: Attestation<E>,
|
|
selection_proof: SelectionProof,
|
|
) -> Result<SignedAggregateAndProof<E>, Error> {
|
|
let signing_epoch = aggregate.data.target.epoch;
|
|
let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch);
|
|
|
|
let message = AggregateAndProof {
|
|
aggregator_index,
|
|
aggregate,
|
|
selection_proof: selection_proof.into(),
|
|
};
|
|
|
|
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::SignedAggregateAndProof(&message),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await?;
|
|
|
|
metrics::inc_counter_vec(&metrics::SIGNED_AGGREGATES_TOTAL, &[metrics::SUCCESS]);
|
|
|
|
Ok(SignedAggregateAndProof { message, signature })
|
|
}
|
|
|
|
/// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to
|
|
/// `validator_pubkey`.
|
|
pub async fn produce_selection_proof(
|
|
&self,
|
|
validator_pubkey: PublicKeyBytes,
|
|
slot: Slot,
|
|
) -> Result<SelectionProof, Error> {
|
|
let signing_epoch = slot.epoch(E::slots_per_epoch());
|
|
let signing_context = self.signing_context(Domain::SelectionProof, signing_epoch);
|
|
|
|
// Bypass the `with_validator_signing_method` function.
|
|
//
|
|
// This is because we don't care about doppelganger protection when it comes to selection
|
|
// proofs. They are not slashable and we need them to subscribe to subnets on the BN.
|
|
//
|
|
// As long as we disallow `SignedAggregateAndProof` then these selection proofs will never
|
|
// be published on the network.
|
|
let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?;
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::SelectionProof(slot),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await
|
|
.map_err(Error::UnableToSign)?;
|
|
|
|
metrics::inc_counter_vec(&metrics::SIGNED_SELECTION_PROOFS_TOTAL, &[metrics::SUCCESS]);
|
|
|
|
Ok(signature.into())
|
|
}
|
|
|
|
/// Produce a `SyncSelectionProof` for `slot` signed by the secret key of `validator_pubkey`.
|
|
pub async fn produce_sync_selection_proof(
|
|
&self,
|
|
validator_pubkey: &PublicKeyBytes,
|
|
slot: Slot,
|
|
subnet_id: SyncSubnetId,
|
|
) -> Result<SyncSelectionProof, Error> {
|
|
let signing_epoch = slot.epoch(E::slots_per_epoch());
|
|
let signing_context =
|
|
self.signing_context(Domain::SyncCommitteeSelectionProof, signing_epoch);
|
|
|
|
// Bypass `with_validator_signing_method`: sync committee messages are not slashable.
|
|
let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?;
|
|
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_SYNC_SELECTION_PROOFS_TOTAL,
|
|
&[metrics::SUCCESS],
|
|
);
|
|
|
|
let message = SyncAggregatorSelectionData {
|
|
slot,
|
|
subcommittee_index: subnet_id.into(),
|
|
};
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::SyncSelectionProof(&message),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await
|
|
.map_err(Error::UnableToSign)?;
|
|
|
|
Ok(signature.into())
|
|
}
|
|
|
|
pub async fn produce_sync_committee_signature(
|
|
&self,
|
|
slot: Slot,
|
|
beacon_block_root: Hash256,
|
|
validator_index: u64,
|
|
validator_pubkey: &PublicKeyBytes,
|
|
) -> Result<SyncCommitteeMessage, Error> {
|
|
let signing_epoch = slot.epoch(E::slots_per_epoch());
|
|
let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch);
|
|
|
|
// Bypass `with_validator_signing_method`: sync committee messages are not slashable.
|
|
let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?;
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::SyncCommitteeSignature {
|
|
beacon_block_root,
|
|
slot,
|
|
},
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await
|
|
.map_err(Error::UnableToSign)?;
|
|
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL,
|
|
&[metrics::SUCCESS],
|
|
);
|
|
|
|
Ok(SyncCommitteeMessage {
|
|
slot,
|
|
beacon_block_root,
|
|
validator_index,
|
|
signature,
|
|
})
|
|
}
|
|
|
|
pub async fn produce_signed_contribution_and_proof(
|
|
&self,
|
|
aggregator_index: u64,
|
|
aggregator_pubkey: PublicKeyBytes,
|
|
contribution: SyncCommitteeContribution<E>,
|
|
selection_proof: SyncSelectionProof,
|
|
) -> Result<SignedContributionAndProof<E>, Error> {
|
|
let signing_epoch = contribution.slot.epoch(E::slots_per_epoch());
|
|
let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch);
|
|
|
|
// Bypass `with_validator_signing_method`: sync committee messages are not slashable.
|
|
let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?;
|
|
|
|
let message = ContributionAndProof {
|
|
aggregator_index,
|
|
contribution,
|
|
selection_proof: selection_proof.into(),
|
|
};
|
|
|
|
let signature = signing_method
|
|
.get_signature::<E, BlindedPayload<E>>(
|
|
SignableMessage::SignedContributionAndProof(&message),
|
|
signing_context,
|
|
&self.spec,
|
|
&self.task_executor,
|
|
)
|
|
.await
|
|
.map_err(Error::UnableToSign)?;
|
|
|
|
metrics::inc_counter_vec(
|
|
&metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL,
|
|
&[metrics::SUCCESS],
|
|
);
|
|
|
|
Ok(SignedContributionAndProof { message, signature })
|
|
}
|
|
|
|
pub fn import_slashing_protection(
|
|
&self,
|
|
interchange: Interchange,
|
|
) -> Result<(), InterchangeError> {
|
|
self.slashing_protection
|
|
.import_interchange_info(interchange, self.genesis_validators_root)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Export slashing protection data while also disabling the given keys in the database.
|
|
///
|
|
/// If any key is unknown to the slashing protection database it will be silently omitted
|
|
/// from the result. It is the caller's responsibility to check whether all keys provided
|
|
/// had data returned for them.
|
|
pub fn export_slashing_protection_for_keys(
|
|
&self,
|
|
pubkeys: &[PublicKeyBytes],
|
|
) -> Result<Interchange, InterchangeError> {
|
|
self.slashing_protection.with_transaction(|txn| {
|
|
let known_pubkeys = pubkeys
|
|
.iter()
|
|
.filter_map(|pubkey| {
|
|
let validator_id = self
|
|
.slashing_protection
|
|
.get_validator_id_ignoring_status(txn, pubkey)
|
|
.ok()?;
|
|
|
|
Some(
|
|
self.slashing_protection
|
|
.update_validator_status(txn, validator_id, false)
|
|
.map(|()| *pubkey),
|
|
)
|
|
})
|
|
.collect::<Result<Vec<PublicKeyBytes>, _>>()?;
|
|
self.slashing_protection.export_interchange_info_in_txn(
|
|
self.genesis_validators_root,
|
|
Some(&known_pubkeys),
|
|
txn,
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Prune the slashing protection database so that it remains performant.
|
|
///
|
|
/// This function will only do actual pruning periodically, so it should usually be
|
|
/// cheap to call. The `first_run` flag can be used to print a more verbose message when pruning
|
|
/// runs.
|
|
pub fn prune_slashing_protection_db(&self, current_epoch: Epoch, first_run: bool) {
|
|
// Attempt to prune every SLASHING_PROTECTION_HISTORY_EPOCHs, with a tolerance for
|
|
// missing the epoch that aligns exactly.
|
|
let mut last_prune = self.slashing_protection_last_prune.lock();
|
|
if current_epoch / SLASHING_PROTECTION_HISTORY_EPOCHS
|
|
<= *last_prune / SLASHING_PROTECTION_HISTORY_EPOCHS
|
|
{
|
|
return;
|
|
}
|
|
|
|
if first_run {
|
|
info!(
|
|
self.log,
|
|
"Pruning slashing protection DB";
|
|
"epoch" => current_epoch,
|
|
"msg" => "pruning may take several minutes the first time it runs"
|
|
);
|
|
} else {
|
|
info!(self.log, "Pruning slashing protection DB"; "epoch" => current_epoch);
|
|
}
|
|
|
|
let _timer = metrics::start_timer(&metrics::SLASHING_PROTECTION_PRUNE_TIMES);
|
|
|
|
let new_min_target_epoch = current_epoch.saturating_sub(SLASHING_PROTECTION_HISTORY_EPOCHS);
|
|
let new_min_slot = new_min_target_epoch.start_slot(E::slots_per_epoch());
|
|
|
|
let all_pubkeys: Vec<_> = self.voting_pubkeys(DoppelgangerStatus::ignored);
|
|
|
|
if let Err(e) = self
|
|
.slashing_protection
|
|
.prune_all_signed_attestations(all_pubkeys.iter(), new_min_target_epoch)
|
|
{
|
|
error!(
|
|
self.log,
|
|
"Error during pruning of signed attestations";
|
|
"error" => ?e,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = self
|
|
.slashing_protection
|
|
.prune_all_signed_blocks(all_pubkeys.iter(), new_min_slot)
|
|
{
|
|
error!(
|
|
self.log,
|
|
"Error during pruning of signed blocks";
|
|
"error" => ?e,
|
|
);
|
|
return;
|
|
}
|
|
|
|
*last_prune = current_epoch;
|
|
|
|
info!(self.log, "Completed pruning of slashing protection DB");
|
|
}
|
|
}
|