Skip to content

fix: reject duplicate validator keys, canonicalize digest order - #450

Open
samlaf wants to merge 1 commit into
mainfrom
genesis-validator-set-invariants
Open

fix: reject duplicate validator keys, canonicalize digest order#450
samlaf wants to merge 1 commit into
mainfrom
genesis-validator-set-invariants

Conversation

@samlaf

@samlaf samlaf commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Human Summary

2 changes related to processing the validator entries in the genesis file:

  1. (bug fix): we didn't check for duplicate validator entries
  2. (clearner semantic): genesis_digest ssz serialized the validators in the order given in the file, and (I think?) panic'd somewhere later if the validators were not ordered by their address. This means any genesis file tooling had to make sure to emit the validators in order (see here for one example). This PR changes the summit genesis ingestion logic to sort the validators before serializing them, so that genesis files are free to order their validators however they wish.

LLM Summary

Two ways a genesis file could quietly mean something other than what it says.

A repeated node public key silently shrank the validator set. The genesis committee is inserted into consensus state keyed by node key (get_initial_state -> set_account) and consensus reads the committee back out of that map, so two entries naming one key launched a network with one fewer validator than the file listed, computing quorum over the smaller set, while startup logged the file's count. Nothing rejected it at load. A repeated consensus key is now rejected for the same reason — it is one signing identity under two names. Repeated withdrawal_credentials stay legal: one operator may run several validators and be paid at one address.

Keys are compared as decoded bytes, so the same key written two ways (0x prefix, upper case) is still caught. Hex-decodability is now required at load rather than deferred to committee construction, since neither that comparison nor the ordering below is defined without it.

Validator order was part of chain identity. config_digest hashes the SSZ encoding of the validator list, which follows stored order, so two files naming the same set in different orders derived different chain domains and their nodes could not authenticate each other as peers. The digest now sorts by node key before hashing. Sorting rather than rejecting unsorted input means no emitter has to be trusted to have sorted, an obligation every future writer of a genesis file would otherwise carry.

That is a semantic change to a value deriving every consensus signing domain, but only for files that were unsorted. Every genesis Summit's tooling emits is already in node-key order, and the frozen digest vector for example_genesis.toml is unchanged. Bumping the domain tag would instead change every digest, including already-canonical ones, forcing a coordinated restart on running networks for no gain, so the tag stays at -v1.

config_digest panics on a validator key that is not hex, matching genesis_hash directly above it: validate rejects it at load, and a digest derived from a key we could not read would silently place a node in a chain domain of its own.

Suggested Followup (but breaking change)

The digest still hashes the key, hash, and address fields as their hex text, so two files that parse to identical values but spell them differently — 0x prefix present or not, hex case — still derive different chain domains. That is the same class of accident this commit removes for ordering, and it is the reason a genesis file cannot yet be treated as mere transport for its values. The fix is to digest the decoded bytes as fixed-size SSZ fields, which changes every digest and so requires a GENESIS_CONFIG_DOMAIN_TAG bump — free now, a hard fork once any network pins a digest. Worth doing; kept out of this commit so this change stays tag-neutral.

Two ways a genesis file could quietly mean something other than what it
says.

A repeated node public key silently shrank the validator set. The genesis
committee is inserted into consensus state keyed by node key
(`get_initial_state` -> `set_account`) and consensus reads the committee
back out of that map, so two entries naming one key launched a network
with one fewer validator than the file listed, computing quorum over the
smaller set, while startup logged the file's count. Nothing rejected it at
load. A repeated consensus key is now rejected for the same reason — it is
one signing identity under two names. Repeated withdrawal_credentials stay
legal: one operator may run several validators and be paid at one address.

Keys are compared as decoded bytes, so the same key written two ways (`0x`
prefix, upper case) is still caught. Hex-decodability is now required at
load rather than deferred to committee construction, since neither that
comparison nor the ordering below is defined without it.

Validator order was part of chain identity. `config_digest` hashes the SSZ
encoding of the validator list, which follows stored order, so two files
naming the same set in different orders derived different chain domains and
their nodes could not authenticate each other as peers. The digest now
sorts by node key before hashing. Sorting rather than rejecting unsorted
input means no emitter has to be trusted to have sorted, an obligation
every future writer of a genesis file would otherwise carry.

That is a semantic change to a value deriving every consensus signing
domain, but only for files that were unsorted. Every genesis Summit's
tooling emits is already in node-key order, and the frozen digest vector
for example_genesis.toml is unchanged. Bumping the domain tag would
instead change every digest, including already-canonical ones, forcing a
coordinated restart on running networks for no gain, so the tag stays at
-v1.

`config_digest` panics on a validator key that is not hex, matching
`genesis_hash` directly above it: validate rejects it at load, and a digest
derived from a key we could not read would silently place a node in a chain
domain of its own.

Left for a follow-up: the digest still hashes the key, hash, and address
fields as their hex *text*, so two files that parse to identical values but
spell them differently — `0x` prefix present or not, hex case — still derive
different chain domains. That is the same class of accident this commit
removes for ordering, and it is the reason a genesis file cannot yet be
treated as mere transport for its values. The fix is to digest the decoded
bytes as fixed-size SSZ fields, which changes every digest and so requires a
GENESIS_CONFIG_DOMAIN_TAG bump — free now, a hard fork once any network
pins a digest. Worth doing; kept out of this commit so this change stays
tag-neutral.

Tests: duplicate node key, the same key respelled, duplicate consensus key,
repeated withdrawal credentials accepted, non-hex keys rejected, digest
unchanged under reversal, and the frozen vector.
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