Skip to content

fix(storage): reject a data directory from another network - #556

Draft
MegaRedHand wants to merge 2 commits into
fix/resume-db-without-checkpoint-urlfrom
fix/verify-db-genesis-matches
Draft

fix(storage): reject a data directory from another network#556
MegaRedHand wants to merge 2 commits into
fix/resume-db-without-checkpoint-urlfrom
fix/verify-db-genesis-matches

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Stacked on #554 (fix/resume-db-without-checkpoint-url). Base is that branch, so this diff is just the genesis check. Merge #554 first.

🗒️ Description / Motivation

Store::from_db_state decided whether a data directory was ours by comparing genesis_time alone, read from the persisted ChainConfig, and on mismatch logged a warning and returned None — "treat as empty":

if persisted_config.genesis_time != expected_genesis_time {
    warn!(..., "Persisted DB has a different genesis_time; treating as empty");
    return Ok(None);
}

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_range resolves each slot through BlockRoots with no anchor check:

for slot in start_slot..=end_slot {
    let Some(root_bytes) = view.get(Table::BlockRoots, &encode_block_root_key(slot))? else { continue };
    // ... no check that this root belongs to the current chain
}

so for slots the new chain had not reached yet, BlocksByRange served the other network's blocks to peers, who reject them and score-penalize us. (Fork choice is unaffected: compute_lmd_ghost_head is seeded from latest_justified, so foreign roots are unreachable and never accumulate weight.)

2. ChainConfig is { 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 — new verify_state_genesis(state, genesis_time, expected_validators) plus a GenesisConfig::verify_state convenience wrapper, and a GenesisMismatch error 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.rsfrom_db_state takes &GenesisConfig and verifies the finalized state (never pruned, and the state the anchor is rebuilt from) rather than the persisted config. A mismatch is now Error::GenesisMismatch. A missing finalized state is Error::UnexpectedMissingState instead of silently "empty".

bin/ethlambda/src/checkpoint_sync.rsverify_checkpoint_state had its own copy of the same four checks; it now delegates to the shared function, and GenesisTimeMismatch / ValidatorCountMismatch / NonSequentialValidatorIndex / ValidatorPubkeyMismatch collapse into one Genesis(#[from] GenesisMismatch). Its checkpoint-only sanity checks stay put — slot != 0 must 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 — export Error. It was a private type appearing in public signatures, so callers could not name it to match on it.

bin/ethlambda/src/main.rsfetch_initial_state propagates the error (if let Some(store) = Store::from_db_state(..)?) instead of swallowing it with if let Ok(Some(..)).

Correctness / Behavior Guarantees

DB in data directory before after
Ours resumed resumed
Different GENESIS_TIME warn → new anchor written over foreign rows startup aborts, DB untouched
Same GENESIS_TIME, different validator set undetected, resumed as ours startup aborts
Genuinely empty initialize initialize (unchanged)

Aborting is deliberate rather than falling back: there is no safe way to reuse the directory, so the operator has to fix --data-dir or 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 --generateGenesis implies --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 on verify_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/storagefrom_db_state_errors_on_genesis_time_mismatch and from_db_state_errors_on_validator_set_mismatch.

bin/ethlambdafails_when_db_genesis_time_differs (also asserts the original DB still loads under its own genesis, i.e. it was not overwritten) and fails_when_db_validator_set_differs.

One test changed contract rather than being fixed: from_db_state_returns_none_on_genesis_time_mismatch encoded 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.

make fmt && make lint && make test    # 30 test binaries, 0 failures

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (cargo test --workspace --profile release-fast) — all passing

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant