Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 14 additions & 55 deletions bin/ethlambda/src/checkpoint_sync.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 {
Expand Down
47 changes: 39 additions & 8 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}"
);
}
}
158 changes: 157 additions & 1 deletion crates/common/types/src/genesis.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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<ValidatorPubkeyBytes, D::Error>
Expand Down Expand Up @@ -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,
})
);
}
}
10 changes: 9 additions & 1 deletion crates/storage/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
use ethlambda_types::primitives::H256;
use ethlambda_types::{genesis::GenesisMismatch, primitives::H256};

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("storage error: {0}")]
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),
}
3 changes: 3 additions & 0 deletions crates/storage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Loading
Loading