diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index ff466ec1..e73f0e8f 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -1,6 +1,7 @@ use std::time::Duration; use ethlambda_types::block::SignedBlock; +use ethlambda_types::genesis::{GenesisMismatch, verify_state_genesis}; use ethlambda_types::primitives::HashTreeRoot as _; use ethlambda_types::state::{State, Validator, anchor_pair_is_consistent}; use libssz::{DecodeError, SszDecode}; @@ -45,20 +46,13 @@ pub enum CheckpointSyncError { SlotIsZero, #[error("checkpoint state has no validators")] NoValidators, - #[error("genesis time mismatch: expected {expected}, got {got}")] - GenesisTimeMismatch { expected: u64, got: u64 }, - #[error("validator count mismatch: expected {expected}, got {got}")] - ValidatorCountMismatch { expected: usize, got: usize }, - #[error( - "validator at position {position} has non-sequential index (expected {expected}, got {got})" - )] - NonSequentialValidatorIndex { - position: usize, - expected: u64, - got: u64, - }, - #[error("validator {index} pubkey mismatch (attestation or proposal key)")] - ValidatorPubkeyMismatch { index: usize }, + #[error("checkpoint state does not match the configured genesis: {0}")] + Genesis(#[from] GenesisMismatch), + /// Reading the persisted store failed, including the case where the data + /// directory holds another network's chain. Startup aborts: the operator + /// has to point at the right data directory or remove it. + #[error("failed to load persisted DB state: {0}")] + DbState(#[from] ethlambda_storage::Error), #[error("finalized slot cannot exceed state slot")] FinalizedExceedsStateSlot, #[error("justified slot cannot precede finalized slot")] @@ -197,7 +191,8 @@ fn verify_checkpoint_state( expected_genesis_time: u64, expected_validators: &[Validator], ) -> Result<(), CheckpointSyncError> { - // Slot sanity check + // Slot sanity check. Checkpoint-specific: unlike a state loaded from our + // own data directory, a downloaded anchor at genesis is never legitimate. if state.slot == 0 { return Err(CheckpointSyncError::SlotIsZero); } @@ -207,46 +202,10 @@ fn verify_checkpoint_state( return Err(CheckpointSyncError::NoValidators); } - // Genesis time matches - if state.config.genesis_time != expected_genesis_time { - return Err(CheckpointSyncError::GenesisTimeMismatch { - expected: expected_genesis_time, - got: state.config.genesis_time, - }); - } - - // Validator count matches - if state.validators.len() != expected_validators.len() { - return Err(CheckpointSyncError::ValidatorCountMismatch { - expected: expected_validators.len(), - got: state.validators.len(), - }); - } - - // Validator indices are sequential (0, 1, 2, ...) - for (position, validator) in state.validators.iter().enumerate() { - if validator.index != position as u64 { - return Err(CheckpointSyncError::NonSequentialValidatorIndex { - position, - expected: position as u64, - got: validator.index, - }); - } - } - - // Validator pubkeys match (critical security check) - for (i, (state_val, expected_val)) in state - .validators - .iter() - .zip(expected_validators.iter()) - .enumerate() - { - if state_val.attestation_pubkey != expected_val.attestation_pubkey - || state_val.proposal_pubkey != expected_val.proposal_pubkey - { - return Err(CheckpointSyncError::ValidatorPubkeyMismatch { index: i }); - } - } + // Genesis time and the full validator registry match our config. Shared + // with the resume-from-disk path so both entry points agree on what makes a + // state ours. + verify_state_genesis(state, expected_genesis_time, expected_validators)?; // Finalized slot sanity if state.latest_finalized.slot > state.slot { diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 2e353b4d..a005a329 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -681,7 +681,7 @@ async fn fetch_initial_state( // have. Tried before the checkpoint-sync and genesis paths so that a restart // without `--checkpoint-sync-url` keeps the chain instead of writing a // slot-0 anchor over it. - if let Ok(Some(store)) = Store::from_db_state(backend.clone(), genesis.genesis_time) { + if let Some(store) = Store::from_db_state(backend.clone(), genesis)? { let now_ms = SystemTime::UNIX_EPOCH .elapsed() .expect("already past the unix epoch") @@ -905,19 +905,50 @@ validators: assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT); } - /// A DB from another network is not resumable, so the no-URL path still - /// falls back to genesis. + /// A DB from another network aborts startup rather than being re-anchored: + /// writing genesis on top would leave the foreign blocks in place, and + /// slot-indexed reads would serve them to peers. #[tokio::test] - async fn initializes_from_genesis_when_db_genesis_time_differs() { + async fn fails_when_db_genesis_time_differs() { let seeded_genesis = test_genesis(now_secs()); let backend = Arc::new(InMemoryBackend::default()); seed_db(backend.clone(), &seeded_genesis); let other_genesis = test_genesis(seeded_genesis.genesis_time + 1); - let store = fetch_initial_state(&[], &other_genesis, backend) - .await - .unwrap(); + // `Store` is not `Debug`, so unwrap the error by pattern. + let Err(err) = fetch_initial_state(&[], &other_genesis, backend.clone()).await else { + panic!("a foreign DB must not be silently re-anchored"); + }; - assert_eq!(store.head_slot(), 0); + assert!( + matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)), + "unexpected error: {err}" + ); + // The foreign chain is left untouched, not overwritten with a new anchor. + let store = Store::from_db_state(backend, &seeded_genesis) + .expect("original DB still loads under its own genesis") + .expect("store exists"); + assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT); + } + + /// Same genesis time, different validator registry: the case the previous + /// `genesis_time`-only check could not see. + #[tokio::test] + async fn fails_when_db_validator_set_differs() { + let genesis_time = now_secs(); + let seeded_genesis = test_genesis(genesis_time); + let backend = Arc::new(InMemoryBackend::default()); + seed_db(backend.clone(), &seeded_genesis); + + let mut other_genesis = test_genesis(genesis_time); + other_genesis.genesis_validators[0].attestation_pubkey = [9u8; 52]; + let Err(err) = fetch_initial_state(&[], &other_genesis, backend).await else { + panic!("a foreign validator set must not be silently re-anchored"); + }; + + assert!( + matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)), + "unexpected error: {err}" + ); } } diff --git a/crates/common/types/src/genesis.rs b/crates/common/types/src/genesis.rs index 27baebf6..239de125 100644 --- a/crates/common/types/src/genesis.rs +++ b/crates/common/types/src/genesis.rs @@ -1,6 +1,25 @@ use serde::Deserialize; -use crate::state::{Validator, ValidatorPubkeyBytes}; +use crate::state::{State, Validator, ValidatorPubkeyBytes}; + +/// Ways a state can fail to belong to the configured genesis. +/// +/// Raised for any state whose provenance we have not established ourselves: +/// one downloaded through checkpoint sync, or one loaded from a data directory +/// that may have been written by a different network. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum GenesisMismatch { + #[error("genesis time mismatch: expected {expected}, got {got}")] + GenesisTime { expected: u64, got: u64 }, + #[error("validator count mismatch: expected {expected}, got {got}")] + ValidatorCount { expected: usize, got: usize }, + #[error( + "validator at position {position} has non-sequential index (expected {position}, got {got})" + )] + NonSequentialIndex { position: usize, got: u64 }, + #[error("validator {index} pubkey mismatch (attestation or proposal key)")] + ValidatorPubkey { index: usize }, +} /// A single validator entry in the genesis config with dual public keys. #[derive(Debug, Clone, Deserialize)] @@ -31,6 +50,65 @@ impl GenesisConfig { }) .collect() } + + /// Verify `state` was produced by this genesis. + /// + /// Compares the genesis time and the full validator registry: count, + /// sequential indices, and both pubkeys per validator. The validator set is + /// fixed at genesis (nothing in the state transition mutates it), so any + /// state of a chain started from this config must carry exactly this + /// registry, whatever slot it sits at. + /// + /// This is a network-identity check, not a consistency check: it says + /// nothing about whether the state is internally coherent. Callers that + /// accept a state from an untrusted source pair it with their own sanity + /// checks. + pub fn verify_state(&self, state: &State) -> Result<(), GenesisMismatch> { + verify_state_genesis(state, self.genesis_time, &self.validators()) + } +} + +/// Verify `state` was produced by the genesis described by `genesis_time` and +/// `expected_validators`. +/// +/// The implementation behind [`GenesisConfig::verify_state`], for callers that +/// hold the genesis time and validator registry separately rather than as a +/// parsed config. +pub fn verify_state_genesis( + state: &State, + genesis_time: u64, + expected_validators: &[Validator], +) -> Result<(), GenesisMismatch> { + if state.config.genesis_time != genesis_time { + return Err(GenesisMismatch::GenesisTime { + expected: genesis_time, + got: state.config.genesis_time, + }); + } + + if state.validators.len() != expected_validators.len() { + return Err(GenesisMismatch::ValidatorCount { + expected: expected_validators.len(), + got: state.validators.len(), + }); + } + + let pairs = state.validators.iter().zip(expected_validators.iter()); + for (position, (actual, expected)) in pairs.enumerate() { + if actual.index != position as u64 { + return Err(GenesisMismatch::NonSequentialIndex { + position, + got: actual.index, + }); + } + if actual.attestation_pubkey != expected.attestation_pubkey + || actual.proposal_pubkey != expected.proposal_pubkey + { + return Err(GenesisMismatch::ValidatorPubkey { index: position }); + } + } + + Ok(()) } fn deser_pubkey_hex<'de, D>(d: D) -> Result @@ -160,4 +238,82 @@ GENESIS_VALIDATORS: ); assert_eq!(block_root, expected_block_root, "block root mismatch"); } + + fn test_config() -> GenesisConfig { + serde_yaml_ng::from_str(TEST_CONFIG_YAML).unwrap() + } + + /// State of a chain started from `config`, advanced past genesis so the + /// check is exercised on something other than the anchor itself. + fn state_of(config: &GenesisConfig) -> State { + let mut state = State::from_genesis(config.genesis_time, config.validators()); + state.slot = 42; + state + } + + #[test] + fn verify_state_accepts_state_from_same_genesis() { + let config = test_config(); + assert_eq!(config.verify_state(&state_of(&config)), Ok(())); + } + + #[test] + fn verify_state_rejects_different_genesis_time() { + let config = test_config(); + let mut other = test_config(); + other.genesis_time = config.genesis_time + 1; + + assert_eq!( + config.verify_state(&state_of(&other)), + Err(GenesisMismatch::GenesisTime { + expected: config.genesis_time, + got: config.genesis_time + 1, + }) + ); + } + + #[test] + fn verify_state_rejects_different_validator_count() { + let config = test_config(); + let mut other = test_config(); + other.genesis_validators.pop(); + + assert_eq!( + config.verify_state(&state_of(&other)), + Err(GenesisMismatch::ValidatorCount { + expected: 3, + got: 2, + }) + ); + } + + /// Same validator count and same genesis time, different keys: the case a + /// genesis-time-only check cannot see. + #[test] + fn verify_state_rejects_different_validator_keys() { + let config = test_config(); + let mut other = test_config(); + other.genesis_validators.swap(0, 1); + + assert_eq!( + config.verify_state(&state_of(&other)), + Err(GenesisMismatch::ValidatorPubkey { index: 0 }) + ); + } + + #[test] + fn verify_state_rejects_non_sequential_validator_indices() { + let config = test_config(); + let mut validators = config.validators(); + validators[1].index = 7; + let state = State::from_genesis(config.genesis_time, validators); + + assert_eq!( + config.verify_state(&state), + Err(GenesisMismatch::NonSequentialIndex { + position: 1, + got: 7, + }) + ); + } } diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs index 3f802aeb..7837cc14 100644 --- a/crates/storage/src/error.rs +++ b/crates/storage/src/error.rs @@ -1,4 +1,4 @@ -use ethlambda_types::primitives::H256; +use ethlambda_types::{genesis::GenesisMismatch, primitives::H256}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -6,4 +6,12 @@ pub enum Error { Storage(#[from] crate::api::Error), #[error("unexpected missing block header for root {0}")] UnexpectedMissingBlockHeader(H256), + #[error("unexpected missing state for root {0}")] + UnexpectedMissingState(H256), + /// The data directory holds a chain from a different network. Refusing to + /// touch it is deliberate: re-initializing on top would leave the foreign + /// blocks in place, and they are reachable through the slot-indexed reads + /// that serve `BlocksByRange`. + #[error("persisted state does not match the configured genesis: {0}")] + GenesisMismatch(#[from] GenesisMismatch), } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 9b21dc85..ffd10301 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -5,4 +5,7 @@ mod state_diff; mod store; pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Table}; +/// Error type returned by the fallible [`Store`] operations, exported so +/// callers can match on it (e.g. to distinguish [`Error::GenesisMismatch`]). +pub use error::Error; pub use store::{ForkCheckpoints, GetForkchoiceStoreError, MAX_RESUMABLE_DB_STATE_AGE, Store}; diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 19193d94..773406af 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -14,6 +14,7 @@ use ethlambda_types::{ Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, SingleMessageAggregate, }, checkpoint::Checkpoint, + genesis::GenesisConfig, primitives::{H256, HashTreeRoot as _}, state::{ChainConfig, State, anchor_pair_is_consistent}, }; @@ -21,7 +22,7 @@ use libssz::{SszDecode, SszEncode}; use crate::state_diff::StateDiff; use thiserror::Error; -use tracing::{info, warn}; +use tracing::{error, info}; /// Errors returned by [`Store::get_forkchoice_store`]. #[derive(Debug, Error)] @@ -606,33 +607,36 @@ impl Store { /// Build a Store from the state already persisted in the storage backend. /// - /// Returns `None` if the backend is empty or its persisted `genesis_time` - /// doesn't match `expected_genesis_time`. + /// Returns `None` when the backend holds no chain state yet, leaving the + /// caller to initialize one from genesis or a checkpoint. + /// + /// # Errors + /// + /// Returns [`Error::GenesisMismatch`] when the persisted chain was started + /// from a different genesis than `genesis`. This is fatal rather than a + /// fall back to "treat the DB as empty": writing a new anchor on top would + /// leave the foreign chain's rows in place, and slot-indexed reads such as + /// [`Self::get_signed_blocks_by_slot_range`] would then serve them to + /// peers. pub fn from_db_state( backend: Arc, - expected_genesis_time: u64, + genesis: &GenesisConfig, ) -> Result, Error> { - let persisted_config = { + { + // Both keys are written by `init_store`, so a backend missing + // either has never held a chain. let view = backend.begin_read().expect("read view"); - let Some(bytes) = view.get(Table::Metadata, KEY_CONFIG).expect("get config") else { - return Ok(None); - }; - if view - .get(Table::Metadata, KEY_LATEST_FINALIZED) - .expect("get latest finalized") - .is_none() - { + let has_chain = view + .get(Table::Metadata, KEY_CONFIG) + .expect("get config") + .is_some() + && view + .get(Table::Metadata, KEY_LATEST_FINALIZED) + .expect("get latest finalized") + .is_some(); + if !has_chain { return Ok(None); } - ChainConfig::from_ssz_bytes(&bytes).expect("valid config") - }; - if persisted_config.genesis_time != expected_genesis_time { - warn!( - db_genesis_time = persisted_config.genesis_time, - expected_genesis_time, - "Persisted DB has a different genesis_time; treating as empty" - ); - return Ok(None); } let store = Self { backend, @@ -643,6 +647,27 @@ impl Store { ))), state_cache: new_state_cache(), }; + + // Compare against the finalized state rather than the persisted + // `ChainConfig`: the config carries only `genesis_time`, so it cannot + // catch a chain that shares our genesis time but not our validator + // set. Finalized is chosen over head because it is the state the + // anchor is rebuilt from and it never gets pruned. + let finalized = store.latest_finalized()?.root; + let state = store + .get_state(&finalized)? + .ok_or(Error::UnexpectedMissingState(finalized))?; + genesis.verify_state(&state).inspect_err(|err| { + error!( + %err, + db_genesis_time = state.config.genesis_time, + db_validators = state.validators.len(), + expected_genesis_time = genesis.genesis_time, + expected_validators = genesis.genesis_validators.len(), + "Persisted DB belongs to a different network; refusing to reuse this data directory" + ) + })?; + info!("Loaded store from persisted DB state"); Ok(Some(store)) } @@ -1706,6 +1731,32 @@ fn write_signed_block( mod tests { use super::*; use crate::backend::InMemoryBackend; + use ethlambda_types::genesis::{GenesisMismatch, GenesisValidatorEntry}; + + /// Validator at `index` whose two pubkeys are filled with `seed`, so + /// changing the seed changes the registry without changing its size. + fn validator(index: u64, seed: u8) -> Validator { + Validator { + attestation_pubkey: [seed; 52], + proposal_pubkey: [seed.wrapping_add(1); 52], + index, + } + } + + /// Genesis config describing a chain started at `genesis_time` with + /// `validators`, for the `from_db_state` identity check. + fn genesis_config(genesis_time: u64, validators: &[Validator]) -> GenesisConfig { + GenesisConfig { + genesis_time, + genesis_validators: validators + .iter() + .map(|v| GenesisValidatorEntry { + attestation_pubkey: v.attestation_pubkey, + proposal_pubkey: v.proposal_pubkey, + }) + .collect(), + } + } /// Insert a block header (and dummy body + signature) for a given root, slot, /// and parent. The stored header equals `header_at(slot, parent_root)`, so a @@ -1902,7 +1953,7 @@ mod tests { .update_checkpoints(ForkCheckpoints::head_only(block_root)) .expect("update head"); - let restored = Store::from_db_state(backend, 12345) + let restored = Store::from_db_state(backend, &genesis_config(12345, &[])) .expect("restore store") .expect("store exists"); let blocks = restored @@ -2939,34 +2990,65 @@ mod tests { fn from_db_state_returns_none_on_empty_backend() { let backend: Arc = Arc::new(InMemoryBackend::new()); assert!( - Store::from_db_state(backend, 12345) + Store::from_db_state(backend, &genesis_config(12345, &[])) .expect("Failed to get store") .is_none() ); } #[test] - fn from_db_state_returns_some_on_matching_genesis_time() { + fn from_db_state_returns_some_on_matching_genesis() { let backend: Arc = Arc::new(InMemoryBackend::new()); // Write an initial state to the backend. let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![])); assert!( - Store::from_db_state(backend, 12345) + Store::from_db_state(backend, &genesis_config(12345, &[])) .expect("Failed to get store") .is_some() ); } + /// Previously this returned `None` ("treat as empty"), which let the caller + /// write a fresh anchor over another network's rows. It is now fatal. #[test] - fn from_db_state_returns_none_on_genesis_time_mismatch() { + fn from_db_state_errors_on_genesis_time_mismatch() { let backend: Arc = Arc::new(InMemoryBackend::new()); // Write an initial state to the backend. let _ = Store::from_anchor_state(backend.clone(), State::from_genesis(12345, vec![])); - assert!( - Store::from_db_state(backend, 99999) - .expect("Failed to get store") - .is_none() + // `Store` is not `Debug`, so unwrap the error by pattern rather than + // with `expect_err`. + let Err(err) = Store::from_db_state(backend, &genesis_config(99999, &[])) else { + panic!("genesis time mismatch must be fatal"); + }; + assert!(matches!( + err, + Error::GenesisMismatch(GenesisMismatch::GenesisTime { + expected: 99999, + got: 12345, + }) + )); + } + + /// The case a `genesis_time`-only check cannot see: same network start + /// time, different validator registry. + #[test] + fn from_db_state_errors_on_validator_set_mismatch() { + let backend: Arc = Arc::new(InMemoryBackend::new()); + let persisted = vec![validator(0, 1), validator(1, 2)]; + let _ = Store::from_anchor_state( + backend.clone(), + State::from_genesis(12345, persisted.clone()), ); + + let mut foreign = persisted; + foreign[1] = validator(1, 9); + let Err(err) = Store::from_db_state(backend, &genesis_config(12345, &foreign)) else { + panic!("validator set mismatch must be fatal"); + }; + assert!(matches!( + err, + Error::GenesisMismatch(GenesisMismatch::ValidatorPubkey { index: 1 }) + )); } #[test] @@ -2985,7 +3067,7 @@ mod tests { .expect("put config"); batch.commit().expect("commit"); assert!( - Store::from_db_state(backend, 12345) + Store::from_db_state(backend, &genesis_config(12345, &[])) .expect("Failed to get store") .is_none() ); diff --git a/docs/checkpoint_sync.md b/docs/checkpoint_sync.md index 46d32446..c63d31ce 100644 --- a/docs/checkpoint_sync.md +++ b/docs/checkpoint_sync.md @@ -62,11 +62,12 @@ A node restarted against a populated data directory resumes from disk rather tha | State in data directory | `--checkpoint-sync-url` | Result | | ------------------------- | ------------------------- | -------- | -| None, or from another network (`GENESIS_TIME` differs) | omitted | Initialize from genesis | -| None, or from another network | set | Checkpoint sync | +| None | omitted | Initialize from genesis | +| None | set | Checkpoint sync | | Present, head within the resume window | either | Resume from disk (no download) | | Present, head beyond the resume window | set | Checkpoint sync | | Present, head beyond the resume window | omitted | Resume from disk anyway, with a warning | +| **From another network** | either | **Startup aborts** (see [Foreign State](#foreign-state)) | The resume window is `MAX_RESUMABLE_DB_STATE_AGE` (450 slots, ~30 minutes at 4-second slots) measured as `current_slot - head_slot`. Staleness is measured against the head, not the finalized checkpoint, so a node whose head is current still resumes during a finality stall. @@ -74,18 +75,26 @@ Beyond that window the node prefers a checkpoint when one is offered, since catc To deliberately discard existing state and start over from genesis or from a checkpoint, remove the data directory first. Checkpoint sync itself writes its anchor state on top without clearing existing data. +### Foreign State + +Persisted state is accepted only after it is verified against the local genesis config: same `GENESIS_TIME` and the same validator registry (count, sequential indices, and both pubkeys per validator). The validator set is fixed at genesis, so any state of this chain must carry exactly that registry. These are the same identity checks checkpoint sync applies to a downloaded state, sharing one implementation. + +If the data directory belongs to a different network, startup **aborts** with `persisted state does not match the configured genesis: …`. It is not treated as an empty directory, because initializing a new anchor on top would leave the foreign chain's rows in place, and the slot-indexed reads behind `BlocksByRange` would then serve those blocks to peers. Point `--data-dir` at the right directory, or remove it. + +Note that a genesis time comparison alone would not catch a network that was regenerated with the same `GENESIS_TIME` but a different validator set, which is why the whole registry is compared. + ## Verification Checks -All checks are performed before the state is accepted: +All checks are performed before a downloaded checkpoint state is accepted. The genesis-identity subset (marked below) is shared with the resume-from-disk path: | Check | What it catches | | ------- | ----------------- | | Slot > 0 | Checkpoint state cannot be genesis (slot 0) | | Validators non-empty | State must contain validators | -| Genesis time matches | Wrong network or misconfigured peer | -| Validator count matches | Validator set size differs from genesis config | -| Sequential validator indices | Indices must be 0, 1, 2, ... in order | -| Validator pubkeys match | Validator identity differs from genesis config | +| Genesis time matches *(shared)* | Wrong network or misconfigured peer | +| Validator count matches *(shared)* | Validator set size differs from genesis config | +| Sequential validator indices *(shared)* | Indices must be 0, 1, 2, ... in order | +| Validator pubkeys match *(shared)* | Validator identity differs from genesis config | | Finalized slot <= state slot | Finalized checkpoint cannot be in the future | | Justified slot >= finalized slot | Justified must be at or after finalized | | Same-slot checkpoints have matching roots | If justified and finalized are at the same slot, they must agree on the root |