Implement slashing protection interchange format (#1544)

## Issue Addressed

Implements support for importing and exporting the slashing protection DB interchange format described here:

https://hackmd.io/@sproul/Bk0Y0qdGD

Also closes #1584 

## Proposed Changes

* [x] Support for serializing and deserializing the format
* [x] Support for importing and exporting Lighthouse's database
* [x] CLI commands to invoke import and export
* [x] Export to minimal format (required when a minimal format has been previously imported)
* [x] Tests for export to minimal (utilising mixed importing and attestation signing?)
* [x] Tests for import/export of complete format, and import of minimal format
* [x] ~~Prevent attestations with sources less than our max source (Danny's suggestion). Required for the fake attestation that we put in for the minimal format to block attestations from source 0.~~
* [x] Add the concept of a "low watermark" for compatibility with the minimal format

Bonus!

* [x] A fix to a potentially nasty bug involving validators getting re-registered each time the validator client ran! Thankfully, the ordering of keys meant that the validator IDs used for attestations and blocks remained stable -- otherwise we could have had some slashings on our hands! 😱
* [x] Tests to confirm that this bug is indeed vanquished
This commit is contained in:
Michael Sproul
2020-10-02 01:42:27 +00:00
parent 22aedda1be
commit 1d278aaa83
25 changed files with 1168 additions and 92 deletions

View File

@@ -0,0 +1,2 @@
interchange-tests
generated-tests

View File

@@ -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"

View File

@@ -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

View File

@@ -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());
}

View File

@@ -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<u64>, 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::<Vec<_>>();
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();
}
}

View File

@@ -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<SignedBlock>,
pub signed_attestations: Vec<SignedAttestation>,
}
#[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<Hash256>,
}
#[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<Hash256>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Interchange {
pub metadata: InterchangeMetadata,
pub data: Vec<CompleteInterchangeData>,
}
impl Interchange {
pub fn from_json_str(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn from_json_reader(reader: impl std::io::Read) -> Result<Self, serde_json::Error> {
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
}
}

View File

@@ -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<TestBlock>,
pub attestations: Vec<TestAttestation>,
}
#[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<Item = (usize, u64, bool)>) -> 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<Item = (usize, u64, u64, bool)>,
) -> 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
);
}
_ => (),
}
}
}
}

View File

@@ -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.

View File

@@ -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::<Vec<_>>();
let get_validator_ids = || {
pubkeys
.iter()
.map(|pk| slashing_db.get_validator_id(pk).unwrap())
.collect::<Vec<_>>()
};
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());
}

View File

@@ -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 {

View File

@@ -12,6 +12,7 @@ pub struct SignedBlock {
#[derive(PartialEq, Debug)]
pub enum InvalidBlock {
DoubleBlockProposal(SignedBlock),
SlotViolatesLowerBound { block_slot: Slot, bound_slot: Slot },
}
impl SignedBlock {

View File

@@ -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<SqliteConnectionManager>;
@@ -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<Item = &'a PublicKey>,
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<i64, NotSafe> {
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<i64, NotSafe> {
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<i64, NotSafe> {
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<Option<i64>, 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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Safe, NotSafe> {
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<Interchange, InterchangeError> {
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<String, (Vec<InterchangeBlock>, Vec<InterchangeAttestation>)> =
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::<Result<_, InterchangeError>>()?;
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::<Result<_, InterchangeError>>()?;
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::<Result<_, InterchangeError>>()?;
Ok(Interchange { metadata, data })
}
pub fn num_validator_rows(&self) -> Result<u32, NotSafe> {
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<NotSafe> for InterchangeError {
fn from(error: NotSafe) -> Self {
InterchangeError::NotSafe(error)
}
}
impl From<rusqlite::Error> for InterchangeError {
fn from(error: rusqlite::Error) -> Self {
Self::SQLError(error.to_string())
}
}
impl From<r2d2::Error> for InterchangeError {
fn from(error: r2d2::Error) -> Self {
InterchangeError::SQLPoolError(error)
}
}
impl From<serde_json::Error> for InterchangeError {
fn from(error: serde_json::Error) -> Self {
InterchangeError::SerdeJsonError(error)
}
}
#[cfg(test)]

View File

@@ -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<T> Default for StreamTest<T> {
}
}
impl<T> StreamTest<T> {
/// 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<AttestationData> {
pub fn run(&self) {
let dir = tempdir().unwrap();
@@ -91,6 +100,8 @@ impl StreamTest<AttestationData> {
i
);
}
roundtrip_database(&dir, &slashing_db, self.num_expected_successes() == 0);
}
}
@@ -112,5 +123,24 @@ impl StreamTest<BeaconBlockHeader> {
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());
}

View File

@@ -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();
}
}