From 166ec91f12dcf2590245914c8b2dbc4eca6f0b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:29:05 -0300 Subject: [PATCH 1/2] chore(beacon): schedule Glamsterdam in the built-in Hoodi config eth-clients/hoodi scheduled Gloas at epoch 132352 (2026-10-26 17:42:48 UTC) in eth-clients/hoodi#31. The built-in Hoodi config is that repo's file byte for byte, so this copies it again at ef89989. A build that claims the GLOAS_* keys needs this to follow Hoodi across the fork: without it, the node keeps the fulu fork digest past epoch 132352 and falls off the chain there. A build that does not claim them only reports the new keys as ignored, the same as it already does for Sepolia. --- bin/ethlambda/assets/hoodi/config.yaml | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bin/ethlambda/assets/hoodi/config.yaml b/bin/ethlambda/assets/hoodi/config.yaml index 884ff8091..72149b8d5 100644 --- a/bin/ethlambda/assets/hoodi/config.yaml +++ b/bin/ethlambda/assets/hoodi/config.yaml @@ -44,6 +44,10 @@ ELECTRA_FORK_EPOCH: 2048 FULU_FORK_VERSION: 0x70000910 FULU_FORK_EPOCH: 50688 +# Gloas +GLOAS_FORK_VERSION: 0x80000910 +GLOAS_FORK_EPOCH: 132352 + # Time parameters # --------------------------------------------------------------- # 12 seconds (*deprecated*) @@ -71,6 +75,22 @@ SYNC_MESSAGE_DUE_BPS: 3333 # 6667 basis points, ~67% of SLOT_DURATION_MS CONTRIBUTION_DUE_BPS: 6667 +# Gloas +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 5000 basis points, 50% of SLOT_DURATION_MS +PAYLOAD_DUE_BPS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + # Validator cycle # --------------------------------------------------------------- # 2**2 (= 4) @@ -94,6 +114,14 @@ MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 # 2**8 * 10**9 (= 256,000,000,000) Gwei MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 +# Gloas +# 2**15 (= 32,768) +CHURN_LIMIT_QUOTIENT_GLOAS: 32768 +# 2**16 (= 65,536) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 256000000000 + # Fork choice # --------------------------------------------------------------- # 40% @@ -174,6 +202,10 @@ BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 # 2**12 (= 4,096) epochs MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + # Blob Scheduling # --------------------------------------------------------------- @@ -183,3 +215,10 @@ BLOB_SCHEDULE: MAX_BLOBS_PER_BLOCK: 15 - EPOCH: 54016 MAX_BLOBS_PER_BLOCK: 21 + +# Gas limit scheduling +# --------------------------------------------------------------- + +GAS_LIMIT_SCHEDULE: + - EPOCH: 132352 + GAS_LIMIT: 200000000 From de572e76a089b023aa60476239531d996744b607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:06:01 -0300 Subject: [PATCH 2/2] fix(beacon): resume a data directory across a fork scheduled ahead of it Scheduling a fork edits exactly the fields a beacon resume compared for equality, so every existing directory was refused with ConfigChanged the first time its node started with the new schedule. Hoodi's Glamsterdam schedule is the first case: on a build that claims GLOAS_*, every Hoodi directory stores gloas as unset and would need a fresh checkpoint sync. A fork's version and epoch now only have to agree once the directory has processed that fork under either schedule: both epochs must lie past the latest epoch it holds a block or state of (the highest LiveChain slot, or the finalized state's slot when later). BLOB_SCHEDULE entries follow the same rule. A fork moved onto or off a processed epoch is still refused, since those blocks were validated under the other schedule. Accepting the change is not enough on its own, because the node runs on the persisted config. A resume that passes now writes the config file over Metadata["config"] (Store::replace_config), so the file's values, CONFIG_NAME included, are the ones the node runs on and reports. --- CLAUDE.md | 16 +- bin/ethlambda/src/checkpoint_sync.rs | 12 +- bin/ethlambda/src/main.rs | 304 +++++++++++++++++++++++---- crates/storage/src/store.rs | 66 +++++- docs/data_storage.md | 39 ++-- 5 files changed, 374 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d4cfc073f..aa339c091 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -784,12 +784,16 @@ behavior. boundary); `get_signed_block` returns `None` for a pruned finalized block. - A `StateDiff` omits `config` and `validators`, trusting they never mutate; breaking that invariant would silently corrupt every reconstructed state. -- `Metadata["config"]` is written once at bootstrap and never rewritten; it - doubles as the DB's genesis-time fingerprint on resume. A beacon resume also - refuses a config file that changes a chain value in it - (`first_config_difference`: fork schedule, slot time, `PRESET_BASE`, ...), - but only warns about a changed `CONFIG_NAME`, a label with no consensus - effect. The stored name is the one the node keeps reporting. +- `Metadata["config"]` is written at bootstrap; it doubles as the DB's + genesis-time fingerprint on resume. A beacon resume also refuses a config + file that changes a chain value in it (`first_config_difference`: slot time, + `PRESET_BASE`, churn limits, ...), but only warns about a changed + `CONFIG_NAME`, a label with no consensus effect. The fork schedule and + `BLOB_SCHEDULE` may change ahead of the chain: a change is refused only when + its old or new epoch is at or before the latest epoch the directory + processed, so a network scheduling a fork does not cost its nodes their + directories. A resume that passes writes the file's config over the stored + one (`Store::replace_config`), since the node runs on the stored config. - `PendingDataColumns` holds *unverified* sidecars parked until their block's parent has a post-state. `data_column_indices_for` reads `DataColumns` only, which is what keeps a parked column from satisfying the availability gate. diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 9030e33f7..ab92d8cc8 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -156,11 +156,13 @@ pub enum CheckpointSyncError { found: ethlambda_storage::Chain, }, /// The directory is for the right chain, but the network config supplied - /// this run disagrees with the one it was initialized with: most likely a - /// fork epoch was edited in place. A changed fork epoch leaves genesis - /// time and the validators root untouched, so it survives the checks - /// above, while putting this node on a different chain from its peers - /// from that epoch on. + /// this run disagrees with the one it was initialized with in a way the + /// directory's contents rule out: most likely a fork epoch was moved onto + /// or off an epoch the directory already processed. A changed fork epoch + /// leaves genesis time and the validators root untouched, so it survives + /// the checks above, while putting this node on a different chain from + /// its peers from that epoch on. A fork rescheduled ahead of everything + /// the directory holds is not this error; the resume adopts it. #[error( "this data directory was initialized with a different configuration ({difference}); \ delete it or point --data-dir elsewhere" diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 5d6f07db6..1ad957541 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -62,6 +62,8 @@ use ethlambda_types::{ BeaconState, SignedBeaconBlock, altair, bellatrix, capella, deneb, electra, phase0, }, beacon::fork::ForkName, + beacon::primitives::Epoch, + beacon::signing::compute_epoch_at_slot, genesis::{GenesisConfig, verify_state_genesis}, state::{State, ValidatorPubkeyBytes}, }; @@ -1465,7 +1467,8 @@ async fn fetch_initial_state( Ok(store) } -/// Name the first configuration field that disagrees with the persisted one. +/// Name the first configuration field that disagrees with the persisted one +/// in a way this directory's contents rule out. /// /// A changed fork epoch leaves genesis time and the validators root untouched, /// so `verify_state_genesis` cannot see it, while putting this node on a @@ -1474,7 +1477,23 @@ async fn fetch_initial_state( /// Lighthouse reports the same situation as an SSZ decode error whose own /// message admits it is guessing between a wrong network and a corrupt /// database. -fn first_config_difference(persisted: &Config, supplied: &Config) -> Option { +/// +/// The fork schedule is compared against what the directory holds rather +/// than for equality, because scheduling a fork is an edit to exactly these +/// fields, made after every existing node initialized its directory. A fork's +/// version and epoch may change as long as this directory has processed +/// nothing of that fork under either schedule, so both the stored and the +/// supplied epoch must lie past `processed_through`, the latest epoch this +/// directory holds a block or state of. That accepts a fork scheduled ahead +/// of the chain (unset before, set now) or moved while still ahead of it. It +/// refuses one moved onto or off epochs already processed, since those were +/// validated under the other schedule. `blob_schedule` follows the same rule: +/// its entries up to `processed_through` must match. +fn first_config_difference( + persisted: &Config, + supplied: &Config, + processed_through: Epoch, +) -> Option { macro_rules! compare { ($($field:ident),+ $(,)?) => { $( @@ -1514,9 +1533,10 @@ fn first_config_difference(persisted: &Config, supplied: &Config) -> Option Option Option processed_through { + continue; + } + let (field, stored, supplied) = if stored_epoch != supplied_epoch { + let field = format!("{fork}_fork_epoch"); + (field, stored_epoch.to_string(), supplied_epoch.to_string()) + } else { + // Phase0's version is the only one not named after its fork. + let field = match fork { + ForkName::Phase0 => "genesis_fork_version".to_string(), + _ => format!("{fork}_fork_version"), + }; + ( + field, + format!("{stored_version:?}"), + format!("{supplied_version:?}"), + ) + }; + return Some(format!( + "{field}: directory has {stored}, config file says {supplied}, \ + and this directory has processed through epoch {processed_through}" + )); + } + + let processed_blob_entries = |config: &Config| -> Vec<_> { + config + .blob_schedule + .iter() + .filter(|entry| entry.epoch <= processed_through) + .copied() + .collect() + }; + let (stored_entries, supplied_entries) = ( + processed_blob_entries(persisted), + processed_blob_entries(supplied), + ); + if stored_entries != supplied_entries { + return Some(format!( + "blob_schedule through epoch {processed_through}: directory has \ + {stored_entries:?}, config file says {supplied_entries:?}" + )); + } + compare!( - preset_base, - genesis_fork_version, - altair_fork_version, - altair_fork_epoch, - bellatrix_fork_version, - bellatrix_fork_epoch, - capella_fork_version, - capella_fork_epoch, - deneb_fork_version, - deneb_fork_epoch, - electra_fork_version, - electra_fork_epoch, - fulu_fork_version, - fulu_fork_epoch, seconds_per_slot, slot_duration_ms, min_validator_withdrawability_delay, @@ -1665,7 +1727,6 @@ fn first_config_difference(persisted: &Config, supplied: &Config) -> Option = Arc::new(InMemoryBackend::new()); + + // First run: fulu not scheduled yet. + let mut unscheduled = network::dir::NetworkDir::load(dir.path()).unwrap(); + let fulu_fork_epoch = unscheduled.config.fulu_fork_epoch; + unscheduled.config.fulu_fork_epoch = FAR_FUTURE_EPOCH; + let source = network::NetworkSource::Loaded(Box::new(unscheduled)); + fetch_initial_beacon_state(&[], backend.clone(), &source) + .await + .expect("first run anchors"); + + // Second run: the file schedules it, far past the genesis anchor. + let scheduled = network::dir::NetworkDir::load(dir.path()).unwrap(); + let source = network::NetworkSource::Loaded(Box::new(scheduled)); + let Ok(store) = fetch_initial_beacon_state(&[], backend.clone(), &source).await else { + panic!("a fork scheduled ahead of the directory must resume"); + }; + assert_eq!(store.config().fulu_fork_epoch, fulu_fork_epoch); + + let reopened = Store::from_db_state(backend) + .expect("reopen") + .expect("populated directory"); + assert_eq!( + reopened.config().fulu_fork_epoch, + fulu_fork_epoch, + "the new schedule must be persisted, not only served" + ); + } + /// `churn_limit_quotient` is one of the state-transition fields the /// original field list omitted entirely: a directory with a different /// churn quotient from its config file accepted a different set of @@ -2669,7 +2813,7 @@ validators: let mut supplied = Config::mainnet(); supplied.churn_limit_quotient += 1; - let difference = first_config_difference(&persisted, &supplied) + let difference = first_config_difference(&persisted, &supplied, 0) .expect("a changed churn_limit_quotient must be caught"); assert!( difference.contains("churn_limit_quotient"), @@ -2686,19 +2830,24 @@ validators: let mut other_preset = Config::mainnet(); other_preset.preset_base = "minimal".try_into().unwrap(); - let difference = first_config_difference(&persisted, &other_preset) + let difference = first_config_difference(&persisted, &other_preset, 0) .expect("a changed PRESET_BASE must be caught"); assert_eq!( difference, r#"preset_base: directory has "mainnet", config file says "minimal""# ); + // Processed through altair's epoch, so moving it is a chain change. + let processed_through = persisted.altair_fork_epoch; let mut renamed = Config::mainnet(); renamed.config_name = "devnet-2".try_into().unwrap(); - assert_eq!(first_config_difference(&persisted, &renamed), None); + assert_eq!( + first_config_difference(&persisted, &renamed, processed_through), + None + ); renamed.altair_fork_epoch += 1; - let difference = first_config_difference(&persisted, &renamed) + let difference = first_config_difference(&persisted, &renamed, processed_through) .expect("a rename must not hide a changed chain value"); assert!( difference.starts_with("altair_fork_epoch:"), @@ -2706,6 +2855,89 @@ validators: ); } + /// A fork's schedule may change while neither the stored nor the supplied + /// epoch has been processed, which covers both scheduling an unset fork + /// and moving one that is still ahead. + #[test] + fn a_fork_schedule_may_change_only_ahead_of_what_was_processed() { + let mut persisted = Config::mainnet(); + persisted.fulu_fork_epoch = FAR_FUTURE_EPOCH; + let mut supplied = Config::mainnet(); + supplied.fulu_fork_epoch = 500_000; + supplied.fulu_fork_version = [0xaa, 0, 0, 0]; + + // Unset, then scheduled ahead of the chain, version included. + assert_eq!( + first_config_difference(&persisted, &supplied, 499_999), + None + ); + // Once that epoch has been processed, the stored schedule is what it + // was processed under. + let difference = first_config_difference(&persisted, &supplied, 500_000) + .expect("a fork scheduled onto a processed epoch must be caught"); + assert!( + difference.starts_with("fulu_fork_epoch:"), + "got {difference}" + ); + + // Moved while still ahead under both schedules. + persisted.fulu_fork_epoch = 600_000; + assert_eq!( + first_config_difference(&persisted, &supplied, 499_999), + None + ); + // Moved later, off an epoch already processed as fulu. + assert!(first_config_difference(&supplied, &persisted, 550_000).is_some()); + + // A version change alone follows the same rule. + let mut reversioned = Config::mainnet(); + reversioned.fulu_fork_version = [0xaa, 0, 0, 0]; + let ahead_of_fulu = Config::mainnet().fulu_fork_epoch - 1; + assert_eq!( + first_config_difference(&Config::mainnet(), &reversioned, ahead_of_fulu), + None + ); + let difference = + first_config_difference(&Config::mainnet(), &reversioned, ahead_of_fulu + 1) + .expect("a processed fork's version must be caught"); + assert!( + difference.starts_with("fulu_fork_version:"), + "got {difference}" + ); + + // Phase0 is always processed, so its version can never change. + let mut regenesis = Config::mainnet(); + regenesis.genesis_fork_version = [0xaa, 0, 0, 0]; + let difference = first_config_difference(&Config::mainnet(), ®enesis, 0) + .expect("genesis_fork_version must be caught"); + assert!( + difference.starts_with("genesis_fork_version:"), + "got {difference}" + ); + } + + /// `BLOB_SCHEDULE` gets the same treatment as the fork schedule: an + /// entry may be added ahead of the chain, not onto a processed epoch. + #[test] + fn a_blob_schedule_entry_may_be_added_only_ahead_of_what_was_processed() { + let persisted = Config::mainnet(); + let mut entries: Vec<_> = persisted.blob_schedule.iter().copied().collect(); + entries.push(BlobScheduleEntry { + epoch: 500_000, + max_blobs_per_block: 48, + }); + let mut supplied = Config::mainnet(); + supplied.blob_schedule = entries.try_into().unwrap(); + + assert_eq!( + first_config_difference(&persisted, &supplied, 499_999), + None + ); + let difference = first_config_difference(&persisted, &supplied, 500_000) + .expect("an entry on a processed epoch must be caught"); + assert!(difference.starts_with("blob_schedule"), "got {difference}"); + } + /// Every built-in network has been live for years, and this follower /// imports nothing at startup, so anchoring at genesis would park it at /// slot 0 while claiming to follow a live chain. That refusal is diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 28b147de7..651328a7c 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -151,8 +151,8 @@ const KEY_BEACON_UNREALIZED_FINALIZED: &[u8] = b"beacon_unrealized_finalized"; /// whether that anchor is genesis or a checkpoint. Its value has type [`u64`] /// and it's SSZ-encoded. /// -/// Written once by each bootstrap path and never rewritten, like [`KEY_CONFIG`] -/// and [`KEY_CHAIN`]. It is the store's only record of where it started: +/// Written once by each bootstrap path and never rewritten, like [`KEY_CHAIN`]. +/// It is the store's only record of where it started: /// [`KEY_LATEST_FINALIZED`] is seeded to the anchor too, but moves with the /// chain, so after the first finalization nothing else on disk can answer this. const KEY_ANCHOR_SLOT: &[u8] = b"anchor_slot"; @@ -176,7 +176,7 @@ pub const DB_VERSION: u64 = 4; /// The consensus protocol a data directory holds. /// -/// Written once at bootstrap and never rewritten, like `Metadata["config"]`. A +/// Written once at bootstrap and never rewritten. A /// directory is one chain or the other for its whole life: the two use /// different state shapes, different checkpoint types and different clock /// units, and nothing migrates between them. @@ -813,7 +813,8 @@ pub struct Store { /// across a mutable borrow of the store it came from. Cloning the `Arc` is /// one atomic increment, not a copy of the fork schedule. /// - /// Written once at bootstrap and never rewritten, so a per-`Store` copy + /// Written at bootstrap, and rewritten only by [`Store::replace_config`], + /// which refuses to run once this copy is shared, so a per-`Store` copy /// cannot go stale. It stays in `Table::Metadata` under `KEY_CONFIG` /// because `from_db_state` reads it back to reject a DB whose genesis time /// or slot duration disagrees with the config file; this field only spares @@ -1654,6 +1655,29 @@ impl Store { Arc::clone(&self.config) } + /// Replaces the persisted configuration with `config`, on disk and in + /// this `Store`'s cached copy. + /// + /// For a resume only, and nothing here checks `config` against what the + /// directory holds: the caller must already have done that (a beacon + /// resume does it in `first_config_difference`). This is what lets a + /// directory follow a fork its network scheduled after the directory was + /// initialized. + /// + /// # Panics + /// + /// If the cached config is shared, that is, if this `Store` has been + /// cloned or a handle from [`Store::config`] is still alive. Either would + /// go on serving the old configuration after this returns. + pub fn replace_config(&mut self, config: Config) { + assert!( + Arc::get_mut(&mut self.config).is_some(), + "replace_config needs the only handle to the cached config" + ); + self.set_metadata(KEY_CONFIG, &config); + self.config = Arc::new(config); + } + /// Which consensus protocol this data directory holds. /// /// Infallible for the same reason [`Store::config`] is: fixed at bootstrap @@ -6849,6 +6873,40 @@ mod tests { assert_eq!(reopened.config().genesis_time, 7); } + #[test] + fn a_replaced_config_is_served_and_survives_a_reopen() { + let backend = Arc::new(InMemoryBackend::new()); + let mut store = Store::from_anchor_state( + backend.clone(), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + + let mut config = (*store.config()).clone(); + config.fulu_fork_epoch = 100; + store.replace_config(config); + assert_eq!(store.config().fulu_fork_epoch, 100); + + let reopened = Store::from_db_state(backend) + .expect("reopen") + .expect("populated directory"); + assert_eq!(reopened.config().fulu_fork_epoch, 100); + } + + #[test] + #[should_panic(expected = "only handle to the cached config")] + fn replacing_the_config_of_a_cloned_store_panics() { + let mut store = Store::from_anchor_state( + Arc::new(InMemoryBackend::new()), + State::from_genesis(7, vec![]), + DEFAULT_MILLISECONDS_PER_SLOT, + ); + // The clone would go on serving the old config. + let _clone = store.clone(); + let config = (*store.config()).clone(); + store.replace_config(config); + } + // ============ Beacon Fork-Choice Scratch Tests ============ #[test] diff --git a/docs/data_storage.md b/docs/data_storage.md index cc238c836..61c4cd445 100644 --- a/docs/data_storage.md +++ b/docs/data_storage.md @@ -306,13 +306,16 @@ the other two cannot, since both presets write the same layout at the same version while bounding every SSZ container differently. None of the three has a migration path. -`config` and `chain` are the odd ones out among the mutable rows: written once -at bootstrap (`init_store` for lean, `init_beacon` for beacon) and never -rewritten afterward (`config` has a getter, `Store::config`, and `chain` has -`Store::chain`, but neither has a setter). Because they never change, the -`Store` keeps a copy of each in memory, and reads of them never reach the -backend. Every other `Metadata` key is mutated in place as the chain -progresses. +`config` and `chain` are the odd ones out among the mutable rows: written at +bootstrap (`init_store` for lean, `init_beacon` for beacon) and not mutated as +the chain progresses. `chain` is never rewritten (it has a getter, +`Store::chain`, and no setter). `config` is rewritten in one place, +`Store::replace_config`, by a beacon resume whose config file changed values +the directory does not contradict (see below), and that method panics unless +its `Store` holds the only handle to the cached copy. Because neither changes +once a `Store` is shared, the `Store` keeps a copy of each in memory, and +reads of them never reach the backend. Every other `Metadata` key is mutated +in place as the chain progresses. `config` used to double as the DB's fingerprint on its own, with `from_db_state` refusing to resume a data directory belonging to another @@ -322,11 +325,23 @@ back and hands both to the caller, which compares `config`'s `genesis_time` and slot duration (and `genesis_validators_root`, computed off the anchored state itself rather than stored in `Metadata`) against the network it was configured for, and `chain` against the sub-command it is running. A beacon -resume goes further, comparing every chain value in `config` (the fork -schedule, the slot time, `PRESET_BASE`, ...) against the `config.yaml` it was -started with, in `first_config_difference`. `CONFIG_NAME` is the exception: a -label with no consensus effect, so a changed one is only warned about, and the -stored name stays, since `config` is never rewritten. +resume goes further, comparing every chain value in `config` (the slot time, +`PRESET_BASE`, the churn limits, ...) against the `config.yaml` it was started +with, in `first_config_difference`. `CONFIG_NAME` is exempt: a label with no +consensus effect, so a changed one is only warned about. + +The fork schedule and `BLOB_SCHEDULE` are compared against what the directory +holds instead, because scheduling a fork is an edit to exactly those values, +made after every existing node initialized its directory. A fork's version or +epoch may change as long as both the stored and the new epoch lie past the +latest epoch the directory has processed: the highest `LiveChain` slot, or the +finalized state's slot when that is later (a checkpoint-sync anchor advanced +through empty slots). So a fork that was unset and is now scheduled ahead of +the chain resumes, while one moved onto or off a processed epoch is refused. +A resume that passes then writes the config file over `config` with +`Store::replace_config`: the node runs on the stored config, so keeping the +old one would leave it on the old schedule. From then on the file's values, +`CONFIG_NAME` included, are the ones the node reports. A beacon directory **shares** `head`, `latest_justified` and `latest_finalized` rather than keeping parallel ones. `init_beacon` seeds all three from one