fix(storage): reject a data directory from another network - #556
Draft
MegaRedHand wants to merge 2 commits into
Draft
fix(storage): reject a data directory from another network#556MegaRedHand wants to merge 2 commits into
MegaRedHand wants to merge 2 commits into
Conversation
`Store::from_db_state` compared only `genesis_time`, taken from the persisted `ChainConfig`, and on mismatch logged a warning and returned `None` — "treat as empty". The caller then wrote a fresh anchor over the foreign chain's rows without clearing them. `get_signed_blocks_by_slot_range` resolves slots through `BlockRoots` with no anchor check, so for slots the new chain had not reached yet `BlocksByRange` served the other network's blocks to peers, who reject them and penalize our score. Worse, `ChainConfig` carries only `genesis_time`, so a network regenerated with the same genesis time but a different validator set was not detected at all: the DB was resumed as if it were ours. Making the DB readable without `--checkpoint-sync-url` (previous commit) puts that case on the default restart path. Compare the whole genesis instead — genesis time plus the full validator registry (count, sequential indices, both pubkeys) — against the finalized state rather than the persisted config, and make a mismatch fatal. The validator set is fixed at genesis, so any state of our chain must carry exactly that registry. Refusing to boot is deliberate: there is no safe way to reuse the directory, and the operator needs to fix --data-dir or remove it. The comparison lives in `ethlambda-types` as `verify_state_genesis`, shared with `checkpoint_sync::verify_checkpoint_state`, which had its own copy of the same four checks; its `GenesisTimeMismatch`/`ValidatorCountMismatch`/ `NonSequentialValidatorIndex`/`ValidatorPubkeyMismatch` variants collapse into one `Genesis(#[from] GenesisMismatch)`. The checkpoint-only sanity checks (slot != 0, finalized <= slot, header pairing) stay there, since a DB legitimately sits at genesis while a downloaded anchor never does. `from_db_state_returns_none_on_genesis_time_mismatch` asserted the old "treat as empty" contract and is replaced by two tests asserting the new fatal one, one of them covering the same-genesis-time case the old check could not see.
…-url' into fix/verify-db-genesis-matches
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🗒️ Description / Motivation
Store::from_db_statedecided whether a data directory was ours by comparinggenesis_timealone, read from the persistedChainConfig, and on mismatch logged a warning and returnedNone— "treat as empty":Two problems with that.
1. "Treat as empty" is not empty. The caller then wrote a fresh anchor over the foreign chain's rows without clearing them.
get_signed_blocks_by_slot_rangeresolves each slot throughBlockRootswith no anchor check:so for slots the new chain had not reached yet,
BlocksByRangeserved the other network's blocks to peers, who reject them and score-penalize us. (Fork choice is unaffected:compute_lmd_ghost_headis seeded fromlatest_justified, so foreign roots are unreachable and never accumulate weight.)2.
ChainConfigis{ genesis_time: u64 }. A network regenerated with the same genesis time but a different validator set was not detected at all — the DB was resumed as if it were ours. #554 makes the DB readable without--checkpoint-sync-url, which puts that case on the default restart path.What Changed
crates/common/types/src/genesis.rs— newverify_state_genesis(state, genesis_time, expected_validators)plus aGenesisConfig::verify_stateconvenience wrapper, and aGenesisMismatcherror enum. Compares 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 our chain must carry exactly that registry, whatever slot it sits at.crates/storage/src/store.rs—from_db_statetakes&GenesisConfigand verifies the finalized state (never pruned, and the state the anchor is rebuilt from) rather than the persisted config. A mismatch is nowError::GenesisMismatch. A missing finalized state isError::UnexpectedMissingStateinstead of silently "empty".bin/ethlambda/src/checkpoint_sync.rs—verify_checkpoint_statehad its own copy of the same four checks; it now delegates to the shared function, andGenesisTimeMismatch/ValidatorCountMismatch/NonSequentialValidatorIndex/ValidatorPubkeyMismatchcollapse into oneGenesis(#[from] GenesisMismatch). Its checkpoint-only sanity checks stay put —slot != 0must not apply to the DB path, since a data directory legitimately sits at genesis while a downloaded anchor never does.crates/storage/src/lib.rs— exportError. It was a private type appearing in public signatures, so callers could not name it to match on it.bin/ethlambda/src/main.rs—fetch_initial_statepropagates the error (if let Some(store) = Store::from_db_state(..)?) instead of swallowing it withif let Ok(Some(..)).Correctness / Behavior Guarantees
GENESIS_TIMEGENESIS_TIME, different validator setAborting is deliberate rather than falling back: there is no safe way to reuse the directory, so the operator has to fix
--data-diror remove it. The error names what differs, e.g.persisted state does not match the configured genesis: validator 1 pubkey mismatch (attestation or proposal key).Operational note for reviewers: any flow that reuses a data directory across a genesis regeneration now fails to boot loudly instead of silently restarting from a fresh anchor.
lean-quickstart's--generateGenesisimplies--cleanData, so local devnets are unaffected; ansible/Hive flows that regenerate genesis in place would need to clear the directory.Tests Added / Run
crates/common/types— 5 tests onverify_state: accepts a state from the same genesis; rejects different genesis time, different validator count, swapped validator keys at the same count and genesis time, and non-sequential indices.crates/storage—from_db_state_errors_on_genesis_time_mismatchandfrom_db_state_errors_on_validator_set_mismatch.bin/ethlambda—fails_when_db_genesis_time_differs(also asserts the original DB still loads under its own genesis, i.e. it was not overwritten) andfails_when_db_validator_set_differs.One test changed contract rather than being fixed:
from_db_state_returns_none_on_genesis_time_mismatchencoded the "treat as empty" behavior this PR removes. It is replaced by the two..._errors_on_...tests above. Flagging explicitly since that is a deliberate contract change, not a broken test.Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(cargo test --workspace --profile release-fast) — all passing