Skip to content

Move tuning knobs and topology out of the hashed genesis file #447

Description

@samlaf

Proposal

The genesis file today holds four kinds of content:

  1. Identity anchors (eth_genesis_hash, namespace) — hashed, immutable. Correct.
  2. Governed consensus params (stakes, epoch length, deposit/withdrawal caps, …) — hashed as initial values; the live values are chain state, changed by transaction via ProtocolParams.sol (param_ids 0x000x09, flowing as execution requests into ConsensusState). Correct: pinned bootstrap value, governed live value.
  3. Liveness tuning (view timeouts, message size) — hashed, with no governance entry and no fork mechanism: the only fields in the file that are frozen forever. Wrong.
  4. Per-validator topology (ip_address) — the only unhashed field (#[ssz(skip_serializing)]), an exception invisible to anyone holding the file. Wrong.

This issue proposes fixing 3 and 4:

  1. Liveness tuning → CLI flags (RunFlags), out of the file and out of config_digest. Per-node, retunable on restart.
  2. validators[].ip_address--bootstrappers, deleting the field. Topology becomes per-boot config, like every other client.

Field by field, using example_genesis.toml:

eth_genesis_hash  = "0x78ab9057…"      # hashed — identity anchor, stays
namespace = "_SUMMIT"                  # hashed — identity anchor, stays
leader_timeout_ms = 2000               # hashed — tuning, frozen today → flag
notarization_timeout_ms = 4000         # hashed — tuning, frozen today → flag
nullify_timeout_ms = 4000              # hashed — tuning, frozen today → flag
skip_timeout_views = 32                # hashed — tuning, frozen today → flag
activity_timeout_views = 256           # hashed — consensus: bounds the
                                       #   vote-acceptance window (batcher's
                                       #   interesting()); stays, and is a
                                       #   governance candidate (see open
                                       #   questions)
max_message_size_bytes = 10485760      # hashed — gray zone, see open questions
validator_minimum_stake = 32000000000  # hashed — governed (ProtocolParams 0x00),
                                       #   stays as the initial value
validator_maximum_stake = 32000000000  # hashed — governed (0x01), stays
blocks_per_epoch = 10000               # hashed — governed (0x02), stays
allowed_timestamp_future_ms = 10000    # hashed — governed (0x03), stays
max_deposits_per_epoch = 3             # hashed — governed (0x05), stays
max_withdrawals_per_epoch = 16         # hashed — governed (0x06), stays
observers_per_validator = 5            # hashed — governed (0x07), stays
minimum_validator_count = 3            # hashed — governed (0x08), stays
# (defaulted fields not shown — treasury_address (0x04), invalid_deposit_tax
#  (0x09) — hashed, governed, stay as initial values)

[[validators]]
node_public_key        = "1be3cb06…"   # hashed — identity, stays
consensus_public_key   = "a6f61154…"   # hashed — identity, stays
withdrawal_credentials = "0xf39F…"     # hashed — identity, stays
ip_address             = "127.0.0.1:26600"  # NOT hashed (#[ssz(skip_serializing)])
                                       #   — topology, → --bootstrappers

End state: config_digest covers literally the whole file — no skip annotations, no invisible exceptions. Every byte of parsed content is identity (initial values for the governed params); everything operational arrives as boot config.

This is a breaking change (config_digestchain_domain changes), so it lands at a network reset. That is the argument for doing it now, before launching mainnet.

Why

Genesis::config_digest() (types/src/genesis.rs) hashes the whole struct minus ip_address, and chain_domain(config_digest) (types/src/lib.rs) is the domain every consensus signature and p2p handshake lives under — checkpoint verification reconstructs it too. So everything in the file is network identity: nodes whose genesis differs in any field cannot handshake. For the governed params that's fine — the file only pins their initial values and the chain evolves them. For the rest of the file it's wrong three ways:

  1. The tuning fields are frozen by accident. They have no ProtocolParams entry and no fork mechanism exists, so today their genesis values are final — and nothing forces this: in timeout-driven BFT, safety is independent of local timeout values under partial synchrony; only liveness degrades on mismatch. They're in the identity hash by colocation, not by necessity. Ironically the least consensus-critical fields in the file are the most frozen. (Contrast allowed_timestamp_future_ms, which feeds block validity, genuinely must be uniform — and is governed.)
  2. The one mutable field doesn't look mutable. An operator holding a genesis file cannot tell that editing ip_address is safe while any other edit breaks interop — the skip-annotation asymmetry exists only in the Rust source.
  3. The deploy tooling's network manifest is moving to pin config_digest as its commitment to the summit genesis, inheriting this coverage judgment wholesale — right layering, so worth getting the judgment right.

Why flags rather than new ProtocolParams entries. Adding the tuning fields as param_ids 0x0a+ would cure the frozen-ness but is the wrong tool: (a) ownership — a governed param is one value network-wide, but a view timeout is local by nature (a validator on slower disks or a distant region legitimately wants a different value); chain-governing it means an operator can't fix their own node without a governance act that retunes everyone, and safety never needed the uniformity. (b) Construction-time circularity — governed params work because the running consensus applies them to its own state at epoch boundaries, whereas these fields configure the p2p/consensus engine at startup, before the node has synced any chain state to read them from (max_message_size_bytes feeds the p2p config directly); state-driven engine knobs would need restart-on-change machinery for values that never needed agreement. The same circularity rules out IPs-on-chain in the strongest form: you need peer addresses to reach the chain at all.

Accept-side vs emit-side timing

All the timeout fields look alike, but they enter the engine on opposite sides of the message boundary (commonware-consensus 2026.4.0):

  • activity_timeout_views shapes what a node accepts from peers: every ingested vote passes through interesting(activity_timeout, last_finalized, current, pending, allow_future) (simplex/actors/batcher/actor.rs; predicate in simplex/mod.rs), which rejects votes for views outside [min_active(activity_timeout, last_finalized), current+1]. Nodes with different values accept different message sets — it must be uniform.
  • leader/notarization/nullify_timeout_ms shape only what a node emits itself: local deadlines deciding when the voter broadcasts its own nullify (simplex/actors/voter/). Incoming peer nullifies are never checked against them — a peer's early nullify makes the batcher shorten the local leader timeout (the LeaderNullify hint, simplex/actors/batcher/actor.rs), not reject the message. Mismatch costs view-synchronization liveness, nothing more.

Accept-side timing must be uniform → hashed (and a governance candidate, see open questions). Emit-side timing is a local preference → flag.

How Ethereum draws this line

  • CL (beacon chain) — summit's structural twin: genesis validators live inside the pinned genesis state root, and signature domains derive from (fork_version, genesis_validators_root), same design as chain_domain = f(config_digest). The CL validator record contains no address of any kind; bootnode ENRs ship as a separate mutable file next to the genesis (boot_enr.yaml) or as flags. (Ethereum can go further than summit: its gossip is open-membership and validators are anonymous at the network layer, so no validator→address mapping exists in the protocol at all. Summit's consensus mesh is closed — peers authenticate by validator key — so it needs a topology input; the point is that this input is boot config, not the hashed genesis.)
  • The tier that never enters a spec: sync timeouts, peer counts, gossip queue sizes — client flags with client-chosen defaults, freely different per node. The rule of thumb: if it's in the chainspec it's consensus; if it's a knob, it's a flag.
  • One place summit is already ahead: parameter-value changes that cost Ethereum a hardfork (EIP-7251's MAX_EFFECTIVE_BALANCE raise, EIP-7514's churn cap) are a ProtocolParams transaction here.

Mechanics sketch

  • Genesis / GenesisValidator lose the tuning and topology fields; their validate() checks move to flag parsing.
  • Moved fields become RunFlags with today's example values as defaults; EngineConfig reads them from flags.
  • --bootstrappers becomes the single topology input for founders and joiners alike. The code is most of the way there already: ingress seeding prefers --bootstrappers over committee IPs when provided (run_node_inner); get_node_ip falls back to external-IP resolution; and a post-genesis joiner's key isn't in the genesis at all, so genesis IPs only ever matter for the founding cohort at t=0. Later, once a canonical long-lived network with stable bootnode infrastructure exists, summit can additionally embed well-known defaults per named network the way geth (params/bootnodes.go) and lighthouse (compiled-in boot_enr.yaml) do, with the flag extending them. For measured-image (TEE) deployments the flag stays primary regardless — baked-in addresses would couple the image measurement to one network's topology.
  • deposit_signature_domain(genesis_hash, namespace) is unaffected.
  • Update example_genesis.toml, testnet configs, genesis tooling.

Open questions

  • Gray-zone fields — the test is does it feed validity (or message acceptance / participant derivation), or only liveness? Validity/acceptance stays hashed and uniform; liveness becomes a flag. Calls made so far: activity_timeout_views stays hashed — it bounds the vote-acceptance window (commonware batcher's interesting() filters every ingested vote through it), so nodes with different values accept different message sets. observers_per_validator looked gray but is governed (0x07), already dynamic, stays. Still gray: max_message_size_bytes. For the ms view timeouts (leader/notarization/nullify_timeout_ms), the acceptance path we can find is view-windowed, not clock-windowed — a peer's early nullify shortens the local timer (the batcher's LeaderNullify hint) rather than being rejected — so they drive only the local decision to nullify; if there is an acceptance path keyed on them, they're tier-1 by this same test and the tuning tier shrinks accordingly. Engine-owner call, with a code pointer.
  • activity_timeout_views as a governance candidate. Uniformity-required is not the same as immutable: nothing about the value is constitutive (unlike eth_genesis_hash/namespace), so its natural home is a new ProtocolParams entry (0x0a) — genesis pins the initial value, a transaction retunes it, applied at an epoch boundary. Precedent that governed-and-engine-consumed works: EpochLength (0x02) is handled dynamically via DynamicEpocher::update_length. Needs the same dynamic plumbing for the batcher's window, plus transition semantics across the activation boundary. Same treatment would fit max_message_size_bytes if it proves consensus-coupled, though it feeds p2p construction more deeply.
  • namespace: hand-authored string today; deriving it from a network-level identifier would make cross-network domain separation by-construction rather than by-convention. Fold in or keep separate?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions