diff --git a/.gitignore b/.gitignore index 570bb6cdf..d6b4306ef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ target/ flamegraph.svg perf.data* *.tar.gz -bin/ +/bin diff --git a/Cargo.lock b/Cargo.lock index a94d97af3..34cf85678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "libc", "rand 0.7.3", "rayon", + "slashing_protection", "slog", "slog-async", "slog-term", @@ -3057,6 +3058,7 @@ dependencies = [ "futures 0.3.5", "lighthouse_version", "logging", + "slashing_protection", "slog", "slog-async", "slog-term", @@ -4935,6 +4937,10 @@ dependencies = [ "r2d2_sqlite", "rayon", "rusqlite", + "serde", + "serde_derive", + "serde_json", + "serde_utils", "tempfile", "tree_hash", "types", diff --git a/account_manager/Cargo.toml b/account_manager/Cargo.toml index 7127a2ddf..1d571489d 100644 --- a/account_manager/Cargo.toml +++ b/account_manager/Cargo.toml @@ -32,3 +32,4 @@ validator_dir = { path = "../common/validator_dir" } tokio = { version = "0.2.21", features = ["full"] } eth2_keystore = { path = "../crypto/eth2_keystore" } account_utils = { path = "../common/account_utils" } +slashing_protection = { path = "../validator_client/slashing_protection" } diff --git a/account_manager/src/validator/create.rs b/account_manager/src/validator/create.rs index 0d4566e46..9c503ecc6 100644 --- a/account_manager/src/validator/create.rs +++ b/account_manager/src/validator/create.rs @@ -10,6 +10,7 @@ use directory::{ }; use environment::Environment; use eth2_wallet_manager::WalletManager; +use slashing_protection::{SlashingDatabase, SLASHING_PROTECTION_FILENAME}; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; @@ -178,6 +179,16 @@ pub fn cli_run( .wallet_by_name(&wallet_name) .map_err(|e| format!("Unable to open wallet: {:?}", e))?; + let slashing_protection_path = validator_dir.join(SLASHING_PROTECTION_FILENAME); + let slashing_protection = + SlashingDatabase::open_or_create(&slashing_protection_path).map_err(|e| { + format!( + "Unable to open or create slashing protection database at {}: {:?}", + slashing_protection_path.display(), + e + ) + })?; + for i in 0..n { let voting_password = random_password(); let withdrawal_password = random_password(); @@ -190,7 +201,22 @@ pub fn cli_run( ) .map_err(|e| format!("Unable to create validator keys: {:?}", e))?; - let voting_pubkey = keystores.voting.pubkey().to_string(); + let voting_pubkey = keystores.voting.public_key().ok_or_else(|| { + format!( + "Keystore public key is invalid: {}", + keystores.voting.pubkey() + ) + })?; + + slashing_protection + .register_validator(&voting_pubkey) + .map_err(|e| { + format!( + "Error registering validator {}: {:?}", + voting_pubkey.to_hex_string(), + e + ) + })?; ValidatorDirBuilder::new(validator_dir.clone(), secrets_dir.clone()) .voting_keystore(keystores.voting, voting_password.as_bytes()) @@ -200,7 +226,7 @@ pub fn cli_run( .build() .map_err(|e| format!("Unable to build validator directory: {:?}", e))?; - println!("{}/{}\t0x{}", i + 1, n, voting_pubkey); + println!("{}/{}\t{}", i + 1, n, voting_pubkey.to_hex_string()); } Ok(()) @@ -208,14 +234,18 @@ pub fn cli_run( /// Returns the number of validators that exist in the given `validator_dir`. /// -/// This function just assumes all files and directories, excluding the validator definitions YAML, -/// are validator directories, making it likely to return a higher number than accurate -/// but never a lower one. +/// This function just assumes all files and directories, excluding the validator definitions YAML +/// and slashing protection database are validator directories, making it likely to return a higher +/// number than accurate but never a lower one. fn existing_validator_count>(validator_dir: P) -> Result { fs::read_dir(validator_dir.as_ref()) .map(|iter| { iter.filter_map(|e| e.ok()) - .filter(|e| e.file_name() != OsStr::new(validator_definitions::CONFIG_FILENAME)) + .filter(|e| { + e.file_name() != OsStr::new(validator_definitions::CONFIG_FILENAME) + && e.file_name() + != OsStr::new(slashing_protection::SLASHING_PROTECTION_FILENAME) + }) .count() }) .map_err(|e| format!("Unable to read {:?}: {}", validator_dir.as_ref(), e)) diff --git a/account_manager/src/validator/import.rs b/account_manager/src/validator/import.rs index 1998709d2..8b4a216e8 100644 --- a/account_manager/src/validator/import.rs +++ b/account_manager/src/validator/import.rs @@ -9,6 +9,7 @@ use account_utils::{ ZeroizeString, }; use clap::{App, Arg, ArgMatches}; +use slashing_protection::{SlashingDatabase, SLASHING_PROTECTION_FILENAME}; use std::fs; use std::path::PathBuf; use std::thread::sleep; @@ -75,6 +76,16 @@ pub fn cli_run(matches: &ArgMatches, validator_dir: PathBuf) -> Result<(), Strin let mut defs = ValidatorDefinitions::open_or_create(&validator_dir) .map_err(|e| format!("Unable to open {}: {:?}", CONFIG_FILENAME, e))?; + let slashing_protection_path = validator_dir.join(SLASHING_PROTECTION_FILENAME); + let slashing_protection = + SlashingDatabase::open_or_create(&slashing_protection_path).map_err(|e| { + format!( + "Unable to open or create slashing protection database at {}: {:?}", + slashing_protection_path.display(), + e + ) + })?; + // Collect the paths for the keystores that should be imported. let keystore_paths = match (keystore, keystores_dir) { (Some(keystore), None) => vec![keystore], @@ -105,6 +116,7 @@ pub fn cli_run(matches: &ArgMatches, validator_dir: PathBuf) -> Result<(), Strin // // - Obtain the keystore password, if the user desires. // - Copy the keystore into the `validator_dir`. + // - Register the voting key with the slashing protection database. // - Add the keystore to the validator definitions file. // // Skip keystores that already exist, but exit early if any operation fails. @@ -185,6 +197,20 @@ pub fn cli_run(matches: &ArgMatches, validator_dir: PathBuf) -> Result<(), Strin fs::copy(&src_keystore, &dest_keystore) .map_err(|e| format!("Unable to copy keystore: {:?}", e))?; + // Register with slashing protection. + let voting_pubkey = keystore + .public_key() + .ok_or_else(|| format!("Keystore public key is invalid: {}", keystore.pubkey()))?; + slashing_protection + .register_validator(&voting_pubkey) + .map_err(|e| { + format!( + "Error registering validator {}: {:?}", + voting_pubkey.to_hex_string(), + e + ) + })?; + eprintln!("Successfully imported keystore."); num_imported_keystores += 1; diff --git a/account_manager/src/validator/mod.rs b/account_manager/src/validator/mod.rs index 4c650dad0..99d8da01b 100644 --- a/account_manager/src/validator/mod.rs +++ b/account_manager/src/validator/mod.rs @@ -3,6 +3,7 @@ pub mod deposit; pub mod import; pub mod list; pub mod recover; +pub mod slashing_protection; use crate::VALIDATOR_DIR_FLAG; use clap::{App, Arg, ArgMatches}; @@ -33,6 +34,7 @@ pub fn cli_app<'a, 'b>() -> App<'a, 'b> { .subcommand(import::cli_app()) .subcommand(list::cli_app()) .subcommand(recover::cli_app()) + .subcommand(slashing_protection::cli_app()) } pub fn cli_run(matches: &ArgMatches, env: Environment) -> Result<(), String> { @@ -50,6 +52,9 @@ pub fn cli_run(matches: &ArgMatches, env: Environment) -> Result< (import::CMD, Some(matches)) => import::cli_run(matches, validator_base_dir), (list::CMD, Some(_)) => list::cli_run(validator_base_dir), (recover::CMD, Some(matches)) => recover::cli_run(matches, validator_base_dir), + (slashing_protection::CMD, Some(matches)) => { + slashing_protection::cli_run(matches, env, validator_base_dir) + } (unknown, _) => Err(format!( "{} does not have a {} command. See --help", CMD, unknown diff --git a/account_manager/src/validator/slashing_protection.rs b/account_manager/src/validator/slashing_protection.rs new file mode 100644 index 000000000..53a7edd51 --- /dev/null +++ b/account_manager/src/validator/slashing_protection.rs @@ -0,0 +1,137 @@ +use clap::{App, Arg, ArgMatches}; +use environment::Environment; +use slashing_protection::{ + interchange::Interchange, SlashingDatabase, SLASHING_PROTECTION_FILENAME, +}; +use std::fs::File; +use std::path::PathBuf; +use types::EthSpec; + +pub const CMD: &str = "slashing-protection"; +pub const IMPORT_CMD: &str = "import"; +pub const EXPORT_CMD: &str = "export"; + +pub const IMPORT_FILE_ARG: &str = "IMPORT-FILE"; +pub const EXPORT_FILE_ARG: &str = "EXPORT-FILE"; + +pub fn cli_app<'a, 'b>() -> App<'a, 'b> { + App::new(CMD) + .about("Import or export slashing protection data to or from another client") + .subcommand( + App::new(IMPORT_CMD) + .about("Import an interchange file") + .arg( + Arg::with_name(IMPORT_FILE_ARG) + .takes_value(true) + .value_name("FILE") + .help("The slashing protection interchange file to import (.json)"), + ), + ) + .subcommand( + App::new(EXPORT_CMD) + .about("Export an interchange file") + .arg( + Arg::with_name(EXPORT_FILE_ARG) + .takes_value(true) + .value_name("FILE") + .help("The filename to export the interchange file to"), + ), + ) +} + +pub fn cli_run( + matches: &ArgMatches<'_>, + env: Environment, + validator_base_dir: PathBuf, +) -> Result<(), String> { + let slashing_protection_db_path = validator_base_dir.join(SLASHING_PROTECTION_FILENAME); + + let genesis_validators_root = env + .testnet + .and_then(|testnet_config| { + Some( + testnet_config + .genesis_state + .as_ref()? + .genesis_validators_root, + ) + }) + .ok_or_else(|| { + "Unable to get genesis validators root from testnet config, has genesis occurred?" + })?; + + match matches.subcommand() { + (IMPORT_CMD, Some(matches)) => { + let import_filename: PathBuf = clap_utils::parse_required(&matches, IMPORT_FILE_ARG)?; + let import_file = File::open(&import_filename).map_err(|e| { + format!( + "Unable to open import file at {}: {:?}", + import_filename.display(), + e + ) + })?; + + let interchange = Interchange::from_json_reader(&import_file) + .map_err(|e| format!("Error parsing file for import: {:?}", e))?; + + let slashing_protection_database = + SlashingDatabase::open_or_create(&slashing_protection_db_path).map_err(|e| { + format!( + "Unable to open database at {}: {:?}", + slashing_protection_db_path.display(), + e + ) + })?; + + slashing_protection_database + .import_interchange_info(&interchange, genesis_validators_root) + .map_err(|e| { + format!( + "Error during import, no data imported: {:?}\n\ + IT IS NOT SAFE TO START VALIDATING", + e + ) + })?; + + eprintln!("Import completed successfully"); + + Ok(()) + } + (EXPORT_CMD, Some(matches)) => { + let export_filename: PathBuf = clap_utils::parse_required(&matches, EXPORT_FILE_ARG)?; + + if !slashing_protection_db_path.exists() { + return Err(format!( + "No slashing protection database exists at: {}", + slashing_protection_db_path.display() + )); + } + + let slashing_protection_database = SlashingDatabase::open(&slashing_protection_db_path) + .map_err(|e| { + format!( + "Unable to open database at {}: {:?}", + slashing_protection_db_path.display(), + e + ) + })?; + + let interchange = slashing_protection_database + .export_interchange_info(genesis_validators_root) + .map_err(|e| format!("Error during export: {:?}", e))?; + + let output_file = File::create(export_filename) + .map_err(|e| format!("Error creating output file: {:?}", e))?; + + interchange + .write_to(&output_file) + .map_err(|e| format!("Error writing output file: {:?}", e))?; + + eprintln!("Export completed successfully"); + + Ok(()) + } + ("", _) => Err("No subcommand provided, see --help for options".to_string()), + (command, _) => Err(format!("No such subcommand `{}`", command)), + } +} diff --git a/lighthouse/Cargo.toml b/lighthouse/Cargo.toml index 1daf5f97c..b7468d6a4 100644 --- a/lighthouse/Cargo.toml +++ b/lighthouse/Cargo.toml @@ -38,3 +38,4 @@ account_utils = { path = "../common/account_utils" } [dev-dependencies] tempfile = "3.1.0" validator_dir = { path = "../common/validator_dir" } +slashing_protection = { path = "../validator_client/slashing_protection" } diff --git a/lighthouse/tests/account_manager.rs b/lighthouse/tests/account_manager.rs index 30f885b4e..3c963f5b1 100644 --- a/lighthouse/tests/account_manager.rs +++ b/lighthouse/tests/account_manager.rs @@ -18,6 +18,7 @@ use account_utils::{ validator_definitions::{SigningDefinition, ValidatorDefinition, ValidatorDefinitions}, ZeroizeString, }; +use slashing_protection::{SlashingDatabase, SLASHING_PROTECTION_FILENAME}; use std::env; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Write}; @@ -25,7 +26,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::str::from_utf8; use tempfile::{tempdir, TempDir}; -use types::Keypair; +use types::{Keypair, PublicKey}; use validator_dir::ValidatorDir; // TODO: create tests for the `lighthouse account validator deposit` command. This involves getting @@ -69,6 +70,23 @@ fn dir_child_count>(dir: P) -> usize { fs::read_dir(dir).expect("should read dir").count() } +/// Returns the number of 0x-prefixed children in a directory +/// i.e. validators in the validators dir. +fn dir_validator_count>(dir: P) -> usize { + fs::read_dir(dir) + .unwrap() + .filter(|c| { + c.as_ref() + .unwrap() + .path() + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("0x") + }) + .count() +} + /// Uses `lighthouse account wallet list` to list all wallets. fn list_wallets>(base_dir: P) -> Vec { let output = output_result( @@ -328,19 +346,30 @@ fn validator_create() { let wallet = TestWallet::new(base_dir.path(), "wally"); wallet.create_expect_success(); - assert_eq!(dir_child_count(validator_dir.path()), 0); + assert_eq!(dir_validator_count(validator_dir.path()), 0); let validator = TestValidator::new(validator_dir.path(), secrets_dir.path(), wallet); // Create a validator _without_ storing the withdraw key. - validator.create_expect_success(COUNT_FLAG, 1, false); + let created_validators = validator.create_expect_success(COUNT_FLAG, 1, false); - assert_eq!(dir_child_count(validator_dir.path()), 1); + // Validator should be registered with slashing protection. + check_slashing_protection( + &validator_dir, + created_validators + .iter() + .map(|v| v.voting_keypair(&secrets_dir).unwrap().pk), + ); + drop(created_validators); + + // Number of dir entries should be #validators + 1 for the slashing protection DB + assert_eq!(dir_validator_count(validator_dir.path()), 1); + assert_eq!(dir_child_count(validator_dir.path()), 2); // Create a validator storing the withdraw key. validator.create_expect_success(COUNT_FLAG, 1, true); - assert_eq!(dir_child_count(validator_dir.path()), 2); + assert_eq!(dir_validator_count(validator_dir.path()), 2); // Use the at-most flag with less validators then are in the directory. assert_eq!( @@ -348,7 +377,7 @@ fn validator_create() { 0 ); - assert_eq!(dir_child_count(validator_dir.path()), 2); + assert_eq!(dir_validator_count(validator_dir.path()), 2); // Use the at-most flag with the same number of validators that are in the directory. assert_eq!( @@ -356,7 +385,7 @@ fn validator_create() { 0 ); - assert_eq!(dir_child_count(validator_dir.path()), 2); + assert_eq!(dir_validator_count(validator_dir.path()), 2); // Use the at-most flag with two more number of validators than are in the directory. assert_eq!( @@ -364,7 +393,7 @@ fn validator_create() { 2 ); - assert_eq!(dir_child_count(validator_dir.path()), 4); + assert_eq!(dir_validator_count(validator_dir.path()), 4); // Create multiple validators with the count flag. assert_eq!( @@ -372,7 +401,7 @@ fn validator_create() { 2 ); - assert_eq!(dir_child_count(validator_dir.path()), 6); + assert_eq!(dir_validator_count(validator_dir.path()), 6); } #[test] @@ -445,6 +474,9 @@ fn validator_import_launchpad() { "not-keystore should not be present in dst dir" ); + // Validator should be registered with slashing protection. + check_slashing_protection(&dst_dir, std::iter::once(keystore.public_key().unwrap())); + let defs = ValidatorDefinitions::open(&dst_dir).unwrap(); let expected_def = ValidatorDefinition { @@ -462,3 +494,12 @@ fn validator_import_launchpad() { "validator defs file should be accurate" ); } + +/// Check that all of the given pubkeys have been registered with slashing protection. +fn check_slashing_protection(validator_dir: &TempDir, pubkeys: impl Iterator) { + let slashing_db_path = validator_dir.path().join(SLASHING_PROTECTION_FILENAME); + let slashing_db = SlashingDatabase::open(&slashing_db_path).unwrap(); + for validator_pk in pubkeys { + slashing_db.get_validator_id(&validator_pk).unwrap(); + } +} diff --git a/validator_client/slashing_protection/.gitignore b/validator_client/slashing_protection/.gitignore new file mode 100644 index 000000000..10366122b --- /dev/null +++ b/validator_client/slashing_protection/.gitignore @@ -0,0 +1,2 @@ +interchange-tests +generated-tests diff --git a/validator_client/slashing_protection/Cargo.toml b/validator_client/slashing_protection/Cargo.toml index 8966cb278..6145826fd 100644 --- a/validator_client/slashing_protection/Cargo.toml +++ b/validator_client/slashing_protection/Cargo.toml @@ -12,6 +12,10 @@ rusqlite = { version = "0.23.1", features = ["bundled"] } r2d2 = "0.8.8" r2d2_sqlite = "0.16.0" parking_lot = "0.11.0" +serde = "1.0.110" +serde_derive = "1.0.110" +serde_json = "1.0.52" +serde_utils = { path = "../../consensus/serde_utils" } [dev-dependencies] rayon = "1.3.0" diff --git a/validator_client/slashing_protection/Makefile b/validator_client/slashing_protection/Makefile new file mode 100644 index 000000000..5abb6c0a4 --- /dev/null +++ b/validator_client/slashing_protection/Makefile @@ -0,0 +1,28 @@ +TESTS_TAG := ac393b815b356c95569c028c215232b512df583d +GENERATE_DIR := generated-tests +OUTPUT_DIR := interchange-tests +TARBALL := $(OUTPUT_DIR)-$(TESTS_TAG).tar.gz +ARCHIVE_URL := https://github.com/eth2-clients/slashing-protection-interchange-tests/tarball/$(TESTS_TAG) + +$(OUTPUT_DIR): $(TARBALL) + rm -rf $@ + mkdir $@ + tar --strip-components=1 -xzf $^ -C $@ + +$(TARBALL): + wget $(ARCHIVE_URL) -O $@ + +clean-test-files: + rm -rf $(OUTPUT_DIR) + +clean-archives: + rm -f $(TARBALL) + +generate: + rm -rf $(GENERATE_DIR) + cargo run --release --bin test_generator -- $(GENERATE_DIR) + +clean: clean-test-files clean-archives + +.PHONY: clean clean-archives clean-test-files generate + diff --git a/validator_client/slashing_protection/build.rs b/validator_client/slashing_protection/build.rs new file mode 100644 index 000000000..03abb88b4 --- /dev/null +++ b/validator_client/slashing_protection/build.rs @@ -0,0 +1,7 @@ +fn main() { + let exit_status = std::process::Command::new("make") + .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .status() + .unwrap(); + assert!(exit_status.success()); +} diff --git a/validator_client/slashing_protection/src/bin/test_generator.rs b/validator_client/slashing_protection/src/bin/test_generator.rs new file mode 100644 index 000000000..3522adf3f --- /dev/null +++ b/validator_client/slashing_protection/src/bin/test_generator.rs @@ -0,0 +1,128 @@ +use slashing_protection::interchange::{ + CompleteInterchangeData, Interchange, InterchangeFormat, InterchangeMetadata, + SignedAttestation, SignedBlock, +}; +use slashing_protection::interchange_test::TestCase; +use slashing_protection::test_utils::{pubkey, DEFAULT_GENESIS_VALIDATORS_ROOT}; +use slashing_protection::SUPPORTED_INTERCHANGE_FORMAT_VERSION; +use std::fs::{self, File}; +use std::path::Path; +use types::{Epoch, Hash256, Slot}; + +fn metadata(genesis_validators_root: Hash256) -> InterchangeMetadata { + InterchangeMetadata { + interchange_format: InterchangeFormat::Complete, + interchange_format_version: SUPPORTED_INTERCHANGE_FORMAT_VERSION, + genesis_validators_root, + } +} + +#[allow(clippy::type_complexity)] +fn interchange(data: Vec<(usize, Vec, Vec<(u64, u64)>)>) -> Interchange { + let data = data + .into_iter() + .map(|(pk, blocks, attestations)| CompleteInterchangeData { + pubkey: pubkey(pk), + signed_blocks: blocks + .into_iter() + .map(|slot| SignedBlock { + slot: Slot::new(slot), + signing_root: None, + }) + .collect(), + signed_attestations: attestations + .into_iter() + .map(|(source, target)| SignedAttestation { + source_epoch: Epoch::new(source), + target_epoch: Epoch::new(target), + signing_root: None, + }) + .collect(), + }) + .collect(); + Interchange { + metadata: metadata(DEFAULT_GENESIS_VALIDATORS_ROOT), + data, + } +} + +fn main() { + let single_validator_blocks = + vec![(0, 32, false), (0, 33, true), (0, 31, false), (0, 1, false)]; + let single_validator_attestations = vec![ + (0, 3, 4, false), + (0, 14, 19, false), + (0, 15, 20, false), + (0, 16, 20, false), + (0, 15, 21, true), + ]; + + let tests = vec![ + TestCase::new( + "single_validator_import_only", + interchange(vec![(0, vec![22], vec![(0, 2)])]), + ), + TestCase::new( + "single_validator_single_block", + interchange(vec![(0, vec![32], vec![])]), + ) + .with_blocks(single_validator_blocks.clone()), + TestCase::new( + "single_validator_single_attestation", + interchange(vec![(0, vec![], vec![(15, 20)])]), + ) + .with_attestations(single_validator_attestations.clone()), + TestCase::new( + "single_validator_single_block_and_attestation", + interchange(vec![(0, vec![32], vec![(15, 20)])]), + ) + .with_blocks(single_validator_blocks) + .with_attestations(single_validator_attestations), + TestCase::new( + "single_validator_genesis_attestation", + interchange(vec![(0, vec![], vec![(0, 0)])]), + ) + .with_attestations(vec![(0, 0, 0, false)]), + TestCase::new( + "single_validator_multiple_blocks_and_attestations", + interchange(vec![( + 0, + vec![2, 3, 10, 1200], + vec![(10, 11), (12, 13), (20, 24)], + )]), + ) + .with_blocks(vec![ + (0, 1, false), + (0, 2, false), + (0, 3, false), + (0, 10, false), + (0, 1200, false), + (0, 4, true), + (0, 256, true), + (0, 1201, true), + ]) + .with_attestations(vec![ + (0, 9, 10, false), + (0, 12, 13, false), + (0, 11, 14, false), + (0, 21, 22, false), + (0, 10, 24, false), + (0, 11, 12, true), + (0, 20, 25, true), + ]), + TestCase::new("wrong_genesis_validators_root", interchange(vec![])) + .gvr(Hash256::from_low_u64_be(1)) + .should_fail(), + ]; + // TODO: multi-validator test + + let args = std::env::args().collect::>(); + let output_dir = Path::new(&args[1]); + fs::create_dir_all(output_dir).unwrap(); + + for test in tests { + test.run(); + let f = File::create(output_dir.join(format!("{}.json", test.name))).unwrap(); + serde_json::to_writer(f, &test).unwrap(); + } +} diff --git a/validator_client/slashing_protection/src/interchange.rs b/validator_client/slashing_protection/src/interchange.rs new file mode 100644 index 000000000..71f678c59 --- /dev/null +++ b/validator_client/slashing_protection/src/interchange.rs @@ -0,0 +1,84 @@ +use serde_derive::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::iter::FromIterator; +use types::{Epoch, Hash256, PublicKey, Slot}; + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum InterchangeFormat { + Complete, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InterchangeMetadata { + pub interchange_format: InterchangeFormat, + #[serde(with = "serde_utils::quoted_u64::require_quotes")] + pub interchange_format_version: u64, + pub genesis_validators_root: Hash256, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CompleteInterchangeData { + pub pubkey: PublicKey, + pub signed_blocks: Vec, + pub signed_attestations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SignedBlock { + #[serde(with = "serde_utils::quoted_u64::require_quotes")] + pub slot: Slot, + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_root: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SignedAttestation { + #[serde(with = "serde_utils::quoted_u64::require_quotes")] + pub source_epoch: Epoch, + #[serde(with = "serde_utils::quoted_u64::require_quotes")] + pub target_epoch: Epoch, + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_root: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Interchange { + pub metadata: InterchangeMetadata, + pub data: Vec, +} + +impl Interchange { + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json) + } + + pub fn from_json_reader(reader: impl std::io::Read) -> Result { + serde_json::from_reader(reader) + } + + pub fn write_to(&self, writer: impl std::io::Write) -> Result<(), serde_json::Error> { + serde_json::to_writer(writer, self) + } + + /// Do these two `Interchange`s contain the same data (ignoring ordering)? + pub fn equiv(&self, other: &Self) -> bool { + let self_set = HashSet::<_>::from_iter(self.data.iter()); + let other_set = HashSet::<_>::from_iter(other.data.iter()); + self.metadata == other.metadata && self_set == other_set + } + + /// The number of entries in `data`. + pub fn len(&self) -> usize { + self.data.len() + } + + /// Is the `data` part of the interchange completely empty? + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} diff --git a/validator_client/slashing_protection/src/interchange_test.rs b/validator_client/slashing_protection/src/interchange_test.rs new file mode 100644 index 000000000..cbb8c54a9 --- /dev/null +++ b/validator_client/slashing_protection/src/interchange_test.rs @@ -0,0 +1,151 @@ +use crate::{ + interchange::Interchange, + test_utils::{pubkey, DEFAULT_GENESIS_VALIDATORS_ROOT}, + SlashingDatabase, +}; +use serde_derive::{Deserialize, Serialize}; +use tempfile::tempdir; +use types::{Epoch, Hash256, PublicKey, Slot}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TestCase { + pub name: String, + pub should_succeed: bool, + pub genesis_validators_root: Hash256, + pub interchange: Interchange, + pub blocks: Vec, + pub attestations: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TestBlock { + pub pubkey: PublicKey, + pub slot: Slot, + pub should_succeed: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TestAttestation { + pub pubkey: PublicKey, + pub source_epoch: Epoch, + pub target_epoch: Epoch, + pub should_succeed: bool, +} + +impl TestCase { + pub fn new(name: &str, interchange: Interchange) -> Self { + TestCase { + name: name.into(), + should_succeed: true, + genesis_validators_root: DEFAULT_GENESIS_VALIDATORS_ROOT, + interchange, + blocks: vec![], + attestations: vec![], + } + } + + pub fn gvr(mut self, genesis_validators_root: Hash256) -> Self { + self.genesis_validators_root = genesis_validators_root; + self + } + + pub fn should_fail(mut self) -> Self { + self.should_succeed = false; + self + } + + pub fn with_blocks(mut self, blocks: impl IntoIterator) -> Self { + self.blocks.extend( + blocks + .into_iter() + .map(|(pk, slot, should_succeed)| TestBlock { + pubkey: pubkey(pk), + slot: Slot::new(slot), + should_succeed, + }), + ); + self + } + + pub fn with_attestations( + mut self, + attestations: impl IntoIterator, + ) -> Self { + self.attestations.extend(attestations.into_iter().map( + |(pk, source, target, should_succeed)| TestAttestation { + pubkey: pubkey(pk), + source_epoch: Epoch::new(source), + target_epoch: Epoch::new(target), + should_succeed, + }, + )); + self + } + + pub fn run(&self) { + let dir = tempdir().unwrap(); + let slashing_db_file = dir.path().join("slashing_protection.sqlite"); + let slashing_db = SlashingDatabase::create(&slashing_db_file).unwrap(); + + match slashing_db.import_interchange_info(&self.interchange, self.genesis_validators_root) { + Ok(()) if !self.should_succeed => { + panic!( + "test `{}` succeeded on import when it should have failed", + self.name + ); + } + Err(e) if self.should_succeed => { + panic!( + "test `{}` failed on import when it should have succeeded, error: {:?}", + self.name, e + ); + } + _ => (), + } + + for (i, block) in self.blocks.iter().enumerate() { + match slashing_db.check_and_insert_block_signing_root( + &block.pubkey, + block.slot, + Hash256::random(), + ) { + Ok(safe) if !block.should_succeed => { + panic!( + "block {} from `{}` succeeded when it should have failed: {:?}", + i, self.name, safe + ); + } + Err(e) if block.should_succeed => { + panic!( + "block {} from `{}` failed when it should have succeeded: {:?}", + i, self.name, e + ); + } + _ => (), + } + } + + for (i, att) in self.attestations.iter().enumerate() { + match slashing_db.check_and_insert_attestation_signing_root( + &att.pubkey, + att.source_epoch, + att.target_epoch, + Hash256::random(), + ) { + Ok(safe) if !att.should_succeed => { + panic!( + "attestation {} from `{}` succeeded when it should have failed: {:?}", + i, self.name, safe + ); + } + Err(e) if att.should_succeed => { + panic!( + "attestation {} from `{}` failed when it should have succeeded: {:?}", + i, self.name, e + ); + } + _ => (), + } + } + } +} diff --git a/validator_client/slashing_protection/src/lib.rs b/validator_client/slashing_protection/src/lib.rs index 384523495..a576743aa 100644 --- a/validator_client/slashing_protection/src/lib.rs +++ b/validator_client/slashing_protection/src/lib.rs @@ -1,19 +1,25 @@ mod attestation_tests; mod block_tests; +pub mod interchange; +pub mod interchange_test; mod parallel_tests; +mod registration_tests; mod signed_attestation; mod signed_block; mod slashing_database; -mod test_utils; +pub mod test_utils; pub use crate::signed_attestation::{InvalidAttestation, SignedAttestation}; pub use crate::signed_block::{InvalidBlock, SignedBlock}; -pub use crate::slashing_database::SlashingDatabase; +pub use crate::slashing_database::{SlashingDatabase, SUPPORTED_INTERCHANGE_FORMAT_VERSION}; use rusqlite::Error as SQLError; use std::io::{Error as IOError, ErrorKind}; use std::string::ToString; use types::{Hash256, PublicKey}; +/// The filename within the `validators` directory that contains the slashing protection DB. +pub const SLASHING_PROTECTION_FILENAME: &str = "slashing_protection.sqlite"; + /// The attestation or block is not safe to sign. /// /// This could be because it's slashable, or because an error occurred. diff --git a/validator_client/slashing_protection/src/registration_tests.rs b/validator_client/slashing_protection/src/registration_tests.rs new file mode 100644 index 000000000..40a3d6ee7 --- /dev/null +++ b/validator_client/slashing_protection/src/registration_tests.rs @@ -0,0 +1,32 @@ +#![cfg(test)] + +use crate::test_utils::*; +use crate::*; +use tempfile::tempdir; + +#[test] +fn double_register_validators() { + let dir = tempdir().unwrap(); + let slashing_db_file = dir.path().join("slashing_protection.sqlite"); + let slashing_db = SlashingDatabase::create(&slashing_db_file).unwrap(); + + let num_validators = 100u32; + let pubkeys = (0..num_validators as usize).map(pubkey).collect::>(); + + let get_validator_ids = || { + pubkeys + .iter() + .map(|pk| slashing_db.get_validator_id(pk).unwrap()) + .collect::>() + }; + + assert_eq!(slashing_db.num_validator_rows().unwrap(), 0); + + slashing_db.register_validators(pubkeys.iter()).unwrap(); + assert_eq!(slashing_db.num_validator_rows().unwrap(), num_validators); + let validator_ids = get_validator_ids(); + + slashing_db.register_validators(pubkeys.iter()).unwrap(); + assert_eq!(slashing_db.num_validator_rows().unwrap(), num_validators); + assert_eq!(validator_ids, get_validator_ids()); +} diff --git a/validator_client/slashing_protection/src/signed_attestation.rs b/validator_client/slashing_protection/src/signed_attestation.rs index 3ab586e4e..1c8020614 100644 --- a/validator_client/slashing_protection/src/signed_attestation.rs +++ b/validator_client/slashing_protection/src/signed_attestation.rs @@ -20,6 +20,18 @@ pub enum InvalidAttestation { PrevSurroundsNew { prev: SignedAttestation }, /// The attestation is invalid because its source epoch is greater than its target epoch. SourceExceedsTarget, + /// The attestation is invalid because its source epoch is less than the lower bound on source + /// epochs for this validator. + SourceLessThanLowerBound { + source_epoch: Epoch, + bound_epoch: Epoch, + }, + /// The attestation is invalid because its target epoch is less than or equal to the lower + /// bound on target epochs for this validator. + TargetLessThanOrEqLowerBound { + target_epoch: Epoch, + bound_epoch: Epoch, + }, } impl SignedAttestation { diff --git a/validator_client/slashing_protection/src/signed_block.rs b/validator_client/slashing_protection/src/signed_block.rs index f299871a6..b31628f43 100644 --- a/validator_client/slashing_protection/src/signed_block.rs +++ b/validator_client/slashing_protection/src/signed_block.rs @@ -12,6 +12,7 @@ pub struct SignedBlock { #[derive(PartialEq, Debug)] pub enum InvalidBlock { DoubleBlockProposal(SignedBlock), + SlotViolatesLowerBound { block_slot: Slot, bound_slot: Slot }, } impl SignedBlock { diff --git a/validator_client/slashing_protection/src/slashing_database.rs b/validator_client/slashing_protection/src/slashing_database.rs index cd2413efd..df0b38ecf 100644 --- a/validator_client/slashing_protection/src/slashing_database.rs +++ b/validator_client/slashing_protection/src/slashing_database.rs @@ -1,12 +1,16 @@ +use crate::interchange::{ + CompleteInterchangeData, Interchange, InterchangeFormat, InterchangeMetadata, + SignedAttestation as InterchangeAttestation, SignedBlock as InterchangeBlock, +}; use crate::signed_attestation::InvalidAttestation; use crate::signed_block::InvalidBlock; -use crate::{NotSafe, Safe, SignedAttestation, SignedBlock}; +use crate::{hash256_from_row, NotSafe, Safe, SignedAttestation, SignedBlock}; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; use std::fs::{File, OpenOptions}; use std::path::Path; use std::time::Duration; -use types::{AttestationData, BeaconBlockHeader, Hash256, PublicKey, SignedRoot}; +use types::{AttestationData, BeaconBlockHeader, Epoch, Hash256, PublicKey, SignedRoot, Slot}; type Pool = r2d2::Pool; @@ -20,6 +24,9 @@ pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(test)] pub const CONNECTION_TIMEOUT: Duration = Duration::from_millis(100); +/// Supported version of the interchange format. +pub const SUPPORTED_INTERCHANGE_FORMAT_VERSION: u64 = 4; + #[derive(Debug, Clone)] pub struct SlashingDatabase { conn_pool: Pool, @@ -52,7 +59,7 @@ impl SlashingDatabase { conn.execute( "CREATE TABLE validators ( id INTEGER PRIMARY KEY, - public_key BLOB NOT NULL + public_key BLOB NOT NULL UNIQUE )", params![], )?; @@ -144,15 +151,25 @@ impl SlashingDatabase { ) -> Result<(), NotSafe> { let mut conn = self.conn_pool.get()?; let txn = conn.transaction()?; - { - let mut stmt = txn.prepare("INSERT INTO validators (public_key) VALUES (?1)")?; + self.register_validators_in_txn(public_keys, &txn)?; + txn.commit()?; + Ok(()) + } - for pubkey in public_keys { + /// Register multiple validators inside the given transaction. + /// + /// The caller must commit the transaction for the changes to be persisted. + pub fn register_validators_in_txn<'a>( + &self, + public_keys: impl Iterator, + txn: &Transaction, + ) -> Result<(), NotSafe> { + let mut stmt = txn.prepare("INSERT INTO validators (public_key) VALUES (?1)")?; + for pubkey in public_keys { + if self.get_validator_id_opt(&txn, pubkey)?.is_none() { stmt.execute(&[pubkey.to_hex_string()])?; } } - txn.commit()?; - Ok(()) } @@ -160,14 +177,34 @@ impl SlashingDatabase { /// /// This is NOT the same as a validator index, and depends on the ordering that validators /// are registered with the slashing protection database (and may vary between machines). - fn get_validator_id(txn: &Transaction, public_key: &PublicKey) -> Result { - txn.query_row( - "SELECT id FROM validators WHERE public_key = ?1", - params![&public_key.to_hex_string()], - |row| row.get(0), - ) - .optional()? - .ok_or_else(|| NotSafe::UnregisteredValidator(public_key.clone())) + pub fn get_validator_id(&self, public_key: &PublicKey) -> Result { + let mut conn = self.conn_pool.get()?; + let txn = conn.transaction()?; + self.get_validator_id_in_txn(&txn, public_key) + } + + fn get_validator_id_in_txn( + &self, + txn: &Transaction, + public_key: &PublicKey, + ) -> Result { + self.get_validator_id_opt(txn, public_key)? + .ok_or_else(|| NotSafe::UnregisteredValidator(public_key.clone())) + } + + /// Optional version of `get_validator_id`. + fn get_validator_id_opt( + &self, + txn: &Transaction, + public_key: &PublicKey, + ) -> Result, NotSafe> { + Ok(txn + .query_row( + "SELECT id FROM validators WHERE public_key = ?1", + params![&public_key.to_hex_string()], + |row| row.get(0), + ) + .optional()?) } /// Check a block proposal from `validator_pubkey` for slash safety. @@ -175,10 +212,10 @@ impl SlashingDatabase { &self, txn: &Transaction, validator_pubkey: &PublicKey, - block_header: &BeaconBlockHeader, - domain: Hash256, + slot: Slot, + signing_root: Hash256, ) -> Result { - let validator_id = Self::get_validator_id(txn, validator_pubkey)?; + let validator_id = self.get_validator_id_in_txn(txn, validator_pubkey)?; let existing_block = txn .prepare( @@ -186,25 +223,37 @@ impl SlashingDatabase { FROM signed_blocks WHERE validator_id = ?1 AND slot = ?2", )? - .query_row( - params![validator_id, block_header.slot], - SignedBlock::from_row, - ) + .query_row(params![validator_id, slot], SignedBlock::from_row) .optional()?; if let Some(existing_block) = existing_block { - if existing_block.signing_root == block_header.signing_root(domain) { + if existing_block.signing_root == signing_root { // Same slot and same hash -> we're re-broadcasting a previously signed block - Ok(Safe::SameData) + return Ok(Safe::SameData); } else { // Same epoch but not the same hash -> it's a DoubleBlockProposal - Err(NotSafe::InvalidBlock(InvalidBlock::DoubleBlockProposal( + return Err(NotSafe::InvalidBlock(InvalidBlock::DoubleBlockProposal( existing_block, - ))) + ))); } - } else { - Ok(Safe::Valid) } + + let min_slot = txn + .prepare("SELECT MIN(slot) FROM signed_blocks WHERE validator_id = ?1")? + .query_row(params![validator_id], |row| row.get(0))?; + + if let Some(min_slot) = min_slot { + if slot <= min_slot { + return Err(NotSafe::InvalidBlock( + InvalidBlock::SlotViolatesLowerBound { + block_slot: slot, + bound_slot: min_slot, + }, + )); + } + } + + Ok(Safe::Valid) } /// Check an attestation from `validator_pubkey` for slash safety. @@ -212,12 +261,10 @@ impl SlashingDatabase { &self, txn: &Transaction, validator_pubkey: &PublicKey, - attestation: &AttestationData, - domain: Hash256, + att_source_epoch: Epoch, + att_target_epoch: Epoch, + att_signing_root: Hash256, ) -> Result { - let att_source_epoch = attestation.source.epoch; - let att_target_epoch = attestation.target.epoch; - // Although it's not required to avoid slashing, we disallow attestations // which are obviously invalid by virtue of their source epoch exceeding their target. if att_source_epoch > att_target_epoch { @@ -226,10 +273,10 @@ impl SlashingDatabase { )); } - let validator_id = Self::get_validator_id(txn, validator_pubkey)?; + let validator_id = self.get_validator_id_in_txn(txn, validator_pubkey)?; - // 1. Check for a double vote. Namely, an existing attestation with the same target epoch, - // and a different signing root. + // Check for a double vote. Namely, an existing attestation with the same target epoch, + // and a different signing root. let same_target_att = txn .prepare( "SELECT source_epoch, target_epoch, signing_root @@ -245,7 +292,7 @@ impl SlashingDatabase { if let Some(existing_attestation) = same_target_att { // If the new attestation is identical to the existing attestation, then we already // know that it is safe, and can return immediately. - if existing_attestation.signing_root == attestation.signing_root(domain) { + if existing_attestation.signing_root == att_signing_root { return Ok(Safe::SameData); // Otherwise if the hashes are different, this is a double vote. } else { @@ -255,7 +302,7 @@ impl SlashingDatabase { } } - // 2. Check that no previous vote is surrounding `attestation`. + // Check that no previous vote is surrounding `attestation`. // If there is a surrounding attestation, we only return the most recent one. let surrounding_attestation = txn .prepare( @@ -277,7 +324,7 @@ impl SlashingDatabase { )); } - // 3. Check that no previous vote is surrounded by `attestation`. + // Check that no previous vote is surrounded by `attestation`. // If there is a surrounded attestation, we only return the most recent one. let surrounded_attestation = txn .prepare( @@ -299,6 +346,39 @@ impl SlashingDatabase { )); } + // Check lower bounds: ensure that source is greater than or equal to min source, + // and target is greater than min target. This allows pruning, and compatibility + // with the interchange format. + let min_source = txn + .prepare("SELECT MIN(source_epoch) FROM signed_attestations WHERE validator_id = ?1")? + .query_row(params![validator_id], |row| row.get(0))?; + + if let Some(min_source) = min_source { + if att_source_epoch < min_source { + return Err(NotSafe::InvalidAttestation( + InvalidAttestation::SourceLessThanLowerBound { + source_epoch: att_source_epoch, + bound_epoch: min_source, + }, + )); + } + } + + let min_target = txn + .prepare("SELECT MIN(target_epoch) FROM signed_attestations WHERE validator_id = ?1")? + .query_row(params![validator_id], |row| row.get(0))?; + + if let Some(min_target) = min_target { + if att_target_epoch <= min_target { + return Err(NotSafe::InvalidAttestation( + InvalidAttestation::TargetLessThanOrEqLowerBound { + target_epoch: att_target_epoch, + bound_epoch: min_target, + }, + )); + } + } + // Everything has been checked, return Valid Ok(Safe::Valid) } @@ -311,19 +391,15 @@ impl SlashingDatabase { &self, txn: &Transaction, validator_pubkey: &PublicKey, - block_header: &BeaconBlockHeader, - domain: Hash256, + slot: Slot, + signing_root: Hash256, ) -> Result<(), NotSafe> { - let validator_id = Self::get_validator_id(txn, validator_pubkey)?; + let validator_id = self.get_validator_id_in_txn(txn, validator_pubkey)?; txn.execute( "INSERT INTO signed_blocks (validator_id, slot, signing_root) VALUES (?1, ?2, ?3)", - params![ - validator_id, - block_header.slot, - block_header.signing_root(domain).as_bytes() - ], + params![validator_id, slot, signing_root.as_bytes()], )?; Ok(()) } @@ -336,19 +412,20 @@ impl SlashingDatabase { &self, txn: &Transaction, validator_pubkey: &PublicKey, - attestation: &AttestationData, - domain: Hash256, + att_source_epoch: Epoch, + att_target_epoch: Epoch, + att_signing_root: Hash256, ) -> Result<(), NotSafe> { - let validator_id = Self::get_validator_id(txn, validator_pubkey)?; + let validator_id = self.get_validator_id_in_txn(txn, validator_pubkey)?; txn.execute( "INSERT INTO signed_attestations (validator_id, source_epoch, target_epoch, signing_root) VALUES (?1, ?2, ?3, ?4)", params![ validator_id, - attestation.source.epoch, - attestation.target.epoch, - attestation.signing_root(domain).as_bytes() + att_source_epoch, + att_target_epoch, + att_signing_root.as_bytes() ], )?; Ok(()) @@ -365,17 +442,46 @@ impl SlashingDatabase { validator_pubkey: &PublicKey, block_header: &BeaconBlockHeader, domain: Hash256, + ) -> Result { + self.check_and_insert_block_signing_root( + validator_pubkey, + block_header.slot, + block_header.signing_root(domain), + ) + } + + /// As for `check_and_insert_block_proposal` but without requiring the whole `BeaconBlockHeader`. + pub fn check_and_insert_block_signing_root( + &self, + validator_pubkey: &PublicKey, + slot: Slot, + signing_root: Hash256, ) -> Result { let mut conn = self.conn_pool.get()?; let txn = conn.transaction_with_behavior(TransactionBehavior::Exclusive)?; + let safe = self.check_and_insert_block_signing_root_txn( + validator_pubkey, + slot, + signing_root, + &txn, + )?; + txn.commit()?; + Ok(safe) + } - let safe = self.check_block_proposal(&txn, validator_pubkey, block_header, domain)?; + /// Transactional variant of `check_and_insert_block_signing_root`. + pub fn check_and_insert_block_signing_root_txn( + &self, + validator_pubkey: &PublicKey, + slot: Slot, + signing_root: Hash256, + txn: &Transaction, + ) -> Result { + let safe = self.check_block_proposal(&txn, validator_pubkey, slot, signing_root)?; if safe != Safe::SameData { - self.insert_block_proposal(&txn, validator_pubkey, block_header, domain)?; + self.insert_block_proposal(&txn, validator_pubkey, slot, signing_root)?; } - - txn.commit()?; Ok(safe) } @@ -390,19 +496,238 @@ impl SlashingDatabase { validator_pubkey: &PublicKey, attestation: &AttestationData, domain: Hash256, + ) -> Result { + let attestation_signing_root = attestation.signing_root(domain); + self.check_and_insert_attestation_signing_root( + validator_pubkey, + attestation.source.epoch, + attestation.target.epoch, + attestation_signing_root, + ) + } + + /// As for `check_and_insert_attestation` but without requiring the whole `AttestationData`. + pub fn check_and_insert_attestation_signing_root( + &self, + validator_pubkey: &PublicKey, + att_source_epoch: Epoch, + att_target_epoch: Epoch, + att_signing_root: Hash256, ) -> Result { let mut conn = self.conn_pool.get()?; let txn = conn.transaction_with_behavior(TransactionBehavior::Exclusive)?; - - let safe = self.check_attestation(&txn, validator_pubkey, attestation, domain)?; - - if safe != Safe::SameData { - self.insert_attestation(&txn, validator_pubkey, attestation, domain)?; - } - + let safe = self.check_and_insert_attestation_signing_root_txn( + validator_pubkey, + att_source_epoch, + att_target_epoch, + att_signing_root, + &txn, + )?; txn.commit()?; Ok(safe) } + + /// Transactional variant of `check_and_insert_attestation_signing_root`. + fn check_and_insert_attestation_signing_root_txn( + &self, + validator_pubkey: &PublicKey, + att_source_epoch: Epoch, + att_target_epoch: Epoch, + att_signing_root: Hash256, + txn: &Transaction, + ) -> Result { + let safe = self.check_attestation( + &txn, + validator_pubkey, + att_source_epoch, + att_target_epoch, + att_signing_root, + )?; + + if safe != Safe::SameData { + self.insert_attestation( + &txn, + validator_pubkey, + att_source_epoch, + att_target_epoch, + att_signing_root, + )?; + } + Ok(safe) + } + + /// Import slashing protection from another client in the interchange format. + pub fn import_interchange_info( + &self, + interchange: &Interchange, + genesis_validators_root: Hash256, + ) -> Result<(), InterchangeError> { + let version = interchange.metadata.interchange_format_version; + if version != SUPPORTED_INTERCHANGE_FORMAT_VERSION { + return Err(InterchangeError::UnsupportedVersion(version)); + } + + if genesis_validators_root != interchange.metadata.genesis_validators_root { + return Err(InterchangeError::GenesisValidatorsMismatch { + client: genesis_validators_root, + interchange_file: interchange.metadata.genesis_validators_root, + }); + } + + // Import atomically, to prevent registering validators with partial information. + let mut conn = self.conn_pool.get()?; + let txn = conn.transaction()?; + + for record in &interchange.data { + self.register_validators_in_txn(std::iter::once(&record.pubkey), &txn)?; + + // Insert all signed blocks. + for block in &record.signed_blocks { + self.check_and_insert_block_signing_root_txn( + &record.pubkey, + block.slot, + block.signing_root.unwrap_or_else(Hash256::zero), + &txn, + )?; + } + + // Insert all signed attestations. + for attestation in &record.signed_attestations { + self.check_and_insert_attestation_signing_root_txn( + &record.pubkey, + attestation.source_epoch, + attestation.target_epoch, + attestation.signing_root.unwrap_or_else(Hash256::zero), + &txn, + )?; + } + } + txn.commit()?; + + Ok(()) + } + + pub fn export_interchange_info( + &self, + genesis_validators_root: Hash256, + ) -> Result { + use std::collections::BTreeMap; + + let mut conn = self.conn_pool.get()?; + let txn = conn.transaction()?; + + // Map from internal validator pubkey to blocks and attestation for that pubkey. + let mut data: BTreeMap, Vec)> = + BTreeMap::new(); + + txn.prepare( + "SELECT public_key, slot, signing_root + FROM signed_blocks, validators + WHERE signed_blocks.validator_id = validators.id", + )? + .query_and_then(params![], |row| { + let validator_pubkey: String = row.get(0)?; + let slot = row.get(1)?; + let signing_root = Some(hash256_from_row(2, &row)?); + let signed_block = InterchangeBlock { slot, signing_root }; + data.entry(validator_pubkey) + .or_insert_with(|| (vec![], vec![])) + .0 + .push(signed_block); + Ok(()) + })? + .collect::>()?; + + txn.prepare( + "SELECT public_key, source_epoch, target_epoch, signing_root + FROM signed_attestations, validators + WHERE signed_attestations.validator_id = validators.id", + )? + .query_and_then(params![], |row| { + let validator_pubkey: String = row.get(0)?; + let source_epoch = row.get(1)?; + let target_epoch = row.get(2)?; + let signing_root = Some(hash256_from_row(3, &row)?); + let signed_attestation = InterchangeAttestation { + source_epoch, + target_epoch, + signing_root, + }; + data.entry(validator_pubkey) + .or_insert_with(|| (vec![], vec![])) + .1 + .push(signed_attestation); + Ok(()) + })? + .collect::>()?; + + let metadata = InterchangeMetadata { + interchange_format: InterchangeFormat::Complete, + interchange_format_version: SUPPORTED_INTERCHANGE_FORMAT_VERSION, + genesis_validators_root, + }; + + let data = data + .into_iter() + .map(|(pubkey, (signed_blocks, signed_attestations))| { + Ok(CompleteInterchangeData { + pubkey: pubkey.parse().map_err(InterchangeError::InvalidPubkey)?, + signed_blocks, + signed_attestations, + }) + }) + .collect::>()?; + + Ok(Interchange { metadata, data }) + } + + pub fn num_validator_rows(&self) -> Result { + let mut conn = self.conn_pool.get()?; + let txn = conn.transaction()?; + let count = txn + .prepare("SELECT COALESCE(COUNT(*), 0) FROM validators")? + .query_row(params![], |row| row.get(0))?; + Ok(count) + } +} + +#[derive(Debug)] +pub enum InterchangeError { + UnsupportedVersion(u64), + GenesisValidatorsMismatch { + interchange_file: Hash256, + client: Hash256, + }, + MinimalAttestationSourceAndTargetInconsistent, + SQLError(String), + SQLPoolError(r2d2::Error), + SerdeJsonError(serde_json::Error), + InvalidPubkey(String), + NotSafe(NotSafe), +} + +impl From for InterchangeError { + fn from(error: NotSafe) -> Self { + InterchangeError::NotSafe(error) + } +} + +impl From for InterchangeError { + fn from(error: rusqlite::Error) -> Self { + Self::SQLError(error.to_string()) + } +} + +impl From for InterchangeError { + fn from(error: r2d2::Error) -> Self { + InterchangeError::SQLPoolError(error) + } +} + +impl From for InterchangeError { + fn from(error: serde_json::Error) -> Self { + InterchangeError::SerdeJsonError(error) + } } #[cfg(test)] diff --git a/validator_client/slashing_protection/src/test_utils.rs b/validator_client/slashing_protection/src/test_utils.rs index e95665298..c9320c10d 100644 --- a/validator_client/slashing_protection/src/test_utils.rs +++ b/validator_client/slashing_protection/src/test_utils.rs @@ -1,13 +1,12 @@ -#![cfg(test)] - use crate::*; -use tempfile::tempdir; +use tempfile::{tempdir, TempDir}; use types::{ test_utils::generate_deterministic_keypair, AttestationData, BeaconBlockHeader, Hash256, }; pub const DEFAULT_VALIDATOR_INDEX: usize = 0; pub const DEFAULT_DOMAIN: Hash256 = Hash256::zero(); +pub const DEFAULT_GENESIS_VALIDATORS_ROOT: Hash256 = Hash256::zero(); pub fn pubkey(index: usize) -> PublicKey { generate_deterministic_keypair(index).pk @@ -73,6 +72,16 @@ impl Default for StreamTest { } } +impl StreamTest { + /// The number of test cases that are expected to pass processing successfully. + fn num_expected_successes(&self) -> usize { + self.cases + .iter() + .filter(|case| case.expected.is_ok()) + .count() + } +} + impl StreamTest { pub fn run(&self) { let dir = tempdir().unwrap(); @@ -91,6 +100,8 @@ impl StreamTest { i ); } + + roundtrip_database(&dir, &slashing_db, self.num_expected_successes() == 0); } } @@ -112,5 +123,24 @@ impl StreamTest { i ); } + + roundtrip_database(&dir, &slashing_db, self.num_expected_successes() == 0); } } + +fn roundtrip_database(dir: &TempDir, db: &SlashingDatabase, is_empty: bool) { + let exported = db + .export_interchange_info(DEFAULT_GENESIS_VALIDATORS_ROOT) + .unwrap(); + let new_db = + SlashingDatabase::create(&dir.path().join("roundtrip_slashing_protection.sqlite")).unwrap(); + new_db + .import_interchange_info(&exported, DEFAULT_GENESIS_VALIDATORS_ROOT) + .unwrap(); + let reexported = new_db + .export_interchange_info(DEFAULT_GENESIS_VALIDATORS_ROOT) + .unwrap(); + + assert_eq!(exported, reexported); + assert_eq!(is_empty, exported.is_empty()); +} diff --git a/validator_client/slashing_protection/tests/interop.rs b/validator_client/slashing_protection/tests/interop.rs new file mode 100644 index 000000000..c0ea6b8c6 --- /dev/null +++ b/validator_client/slashing_protection/tests/interop.rs @@ -0,0 +1,23 @@ +use slashing_protection::interchange_test::TestCase; +use std::fs::File; +use std::path::PathBuf; + +fn test_root_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("interchange-tests") + .join("tests") +} + +#[test] +fn generated() { + for entry in test_root_dir() + .join("generated") + .read_dir() + .unwrap() + .map(Result::unwrap) + { + let file = File::open(entry.path()).unwrap(); + let test_case: TestCase = serde_json::from_reader(&file).unwrap(); + test_case.run(); + } +} diff --git a/validator_client/src/config.rs b/validator_client/src/config.rs index 4d230b1b4..1551f1aee 100644 --- a/validator_client/src/config.rs +++ b/validator_client/src/config.rs @@ -10,8 +10,6 @@ use std::path::PathBuf; use types::GRAFFITI_BYTES_LEN; pub const DEFAULT_HTTP_SERVER: &str = "http://localhost:5052/"; -/// Path to the slashing protection database within the datadir. -pub const SLASHING_PROTECTION_FILENAME: &str = "slashing_protection.sqlite"; /// Stores the core configuration for this validator instance. #[derive(Clone, Serialize, Deserialize)] diff --git a/validator_client/src/validator_store.rs b/validator_client/src/validator_store.rs index 66a616ff3..6bf2f211d 100644 --- a/validator_client/src/validator_store.rs +++ b/validator_client/src/validator_store.rs @@ -1,10 +1,8 @@ use crate::{ - config::{Config, SLASHING_PROTECTION_FILENAME}, - fork_service::ForkService, - initialized_validators::InitializedValidators, + config::Config, fork_service::ForkService, initialized_validators::InitializedValidators, }; use parking_lot::RwLock; -use slashing_protection::{NotSafe, Safe, SlashingDatabase}; +use slashing_protection::{NotSafe, Safe, SlashingDatabase, SLASHING_PROTECTION_FILENAME}; use slog::{crit, error, warn, Logger}; use slot_clock::SlotClock; use std::marker::PhantomData;