--- eip: 8450 title: Post-Quantum Consensus Key Registry description: Register dormant hash-based consensus keys for validators and builders ahead of the post-quantum signature switch author: Thomas Coratger (@tcoratger), Kevaundray Wedderburn (@kevaundray), Tom Wambsgans (@TomWambsgans), Justin Drake (@JustinDrake) discussions-to: https://ethereum-magicians.org/t/eip-8450-post-quantum-validator-key-registry/30046 status: Draft type: Standards Track category: Core created: 2026-10-15 requires: 7495, 7732, 7916 --- ## Abstract This EIP adds a post-quantum public key registry to the beacon state. Each validator registers two hash-based commitments: a leanXMSS key, which will sign all its messages once duties switch to it, and the 32-byte root of a verifiable random function (VRF) tree, which fixes one verifiable random output per index. Each builder registers only a leanXMSS key. Registration is a new beacon block operation carrying the keys and a proof of possession that binds the leanXMSS key to the registrant's BLS public key. The registrant's BLS signing key authorizes it and can replace registered keys, at a limited rate. The keys stay dormant until a later fork switches duties to them. ## Motivation Every consensus-layer signature today is a BLS12-381 signature: attestations, block proposals, RANDAO reveals, aggregation selection proofs, sync committee messages, payload attestations, inclusion lists, proposer preferences, voluntary exits, the deposit proof of possession, and the execution payload bids and envelopes of builders. BLS relies on the hardness of discrete logarithms in pairing-friendly groups, which Shor's algorithm breaks. A cryptographically relevant quantum computer (CRQC) recovers a secret key from its public key, and every validator's and builder's public key is in the beacon state. BLS therefore has to be replaced, and this EIP splits the replacement into two forks. Generating a post-quantum key involves the validator's secret key material, requires new tooling, and, for staking pools, requires coordination between node operators and stake owners. If this work happens at the fork that switches the signature scheme, the number of ready validators is only known at that fork. A separate registry fork has the following benefits: - **Preparation.** Validators generate and register keys, with tooling tested on mainnet, while BLS remains the only scheme used for duties. - **A readiness signal.** The effective balance of active validators with registered keys can be computed from the state, so the switch can be scheduled against an observed threshold. - **Early implementation.** Clients run the hash function, the verifier, and the containers on mainnet before any duty depends on them, since every registration carries a proof of possession. - **A recovery anchor.** If a CRQC appears to be near before the switch, a fork can stop key changes. Keys registered before that fork, while BLS was believed secure, are then the keys the chain moves to, without relying on any BLS signature made afterwards. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). The changes apply to the consensus specifications (`ethereum/consensus-specs`) on top of Heze, and are rebased onto the fork that includes this EIP once it is scheduled. The fork that activates this EIP is assigned by the relevant meta EIP. Functions and constants not defined here keep their meaning from those specifications. The *registrant* of a registration is the validator or builder whose index it names. ### Cryptographic helpers This EIP uses two hash-based primitives, a stateful signature scheme and a verifiable random function (VRF), through the interface below. It specifies the interface and the properties it relies on, not the constructions, so that the registry does not depend on how the primitives are built (see [Interface instead of construction](#interface-instead-of-construction)). Key generation, signing, and opening are not consensus-critical. The signature scheme is leanXMSS and the VRF is a hash-based VRF tree. leanXMSS, with 32-byte public keys and 2404-byte signatures, is implemented in the Cryptography/Xmss module of the ethereum/EthLib repository at commit 6390c73ef040311e807b9047d99dca6b1be1c42f. | Function | Description | | - | - | | `xmss.Verify(public_key: XMSSPublicKey, message: Bytes32, signature: XMSSSignature) -> bool` | Return whether `signature` is a valid signature of `message` under `public_key`, at the leaf index it carries. Return `False`, without raising, for a key or signature that does not decode | | `vrf.Verify(root: VRFRoot, index: Uint32, opening: bytes) -> Optional[Bytes32]` | Return the output of the VRF tree with root `root` at `index` if `opening` is a valid opening of it, and `None` otherwise. This EIP does not call it | A signature carries its leaf index in its first 4 bytes, little-endian. The proof-of-possession check reads it with: ```python def xmss_signature_index(signature: XMSSSignature) -> Uint32: """ Return the leaf index that a signature carries. """ return Uint32(int.from_bytes(signature[0:4], "little")) ``` This EIP relies on the following properties: - **Stateful signatures.** Signatures are unforgeable as long as each leaf index of a key signs at most once. Signing is not assumed to be deterministic, so signing the same message twice at one leaf index is also a reuse. Every leaf index in `[0, 2**32)` can be used. - **Unique VRF outputs.** For a given root and index, at most one output verifies, unless a hash collision is known. An output is unpredictable to others until it is opened. ### Constants #### Domains | Name | Value | Description | | - | - | - | | `DOMAIN_PQ_KEY_REGISTRATION` | `DomainType('0x12000000')` | BLS authorization of a registration by the registrant's signing key | | `DOMAIN_PQ_KEY_POSSESSION` | `DomainType('0x13000000')` | Proof of possession under the registered leanXMSS key | Both domains are computed against the genesis fork version, as for `BLSToExecutionChange`, so that registrations remain valid across forks. #### Registry | Name | Value | Description | | - | - | - | | `PQ_POSSESSION_XMSS_INDEX` | `Uint32(0)` | Leaf index at which the proof of possession is signed | ### Preset | Name | Value | Description | | - | - | - | | `MAX_PQ_KEY_REGISTRATIONS` | `Uint64(2**3)` (= 8) | Maximum registrations per beacon block | | `MIN_PQ_KEY_CHANGE_DELAY` | `Epoch(2**8)` (= 256) | Minimum number of epochs between two changes of the keys of an index | The minimal preset sets `MIN_PQ_KEY_CHANGE_DELAY` to `Epoch(2**3)` (= 8). In every preset, `MIN_PQ_KEY_CHANGE_DELAY` MUST be at least `1` (see [Changes and replay protection](#changes-and-replay-protection)), and the Gloas configuration value `MIN_BUILDER_WITHDRAWABILITY_DELAY` MUST be at least `1` (see [Builder index reuse](#builder-index-reuse)). ### Custom types | Name | SSZ equivalent | Description | | - | - | - | | `XMSSPublicKey` | `ByteVector[32]` | A leanXMSS public key | | `XMSSSignature` | `ByteVector[2404]` | A leanXMSS signature | | `VRFRoot` | `Bytes32` | The root of a VRF tree | ### Containers #### New containers ```python class PQKeyMaterial(Container): """ The post-quantum keys of one validator or builder, as stored in the registry. Serializes to 32 + 32 = 64 bytes. """ # leanXMSS public key. xmss_public_key: XMSSPublicKey # Root of the VRF tree, or zero for a builder. vrf_root: VRFRoot ``` ```python class PQKeyRegistration(Container): """ The message of a registration: which registrant, which keys, and after which change. """ # Validator index, or builder index with BUILDER_INDEX_FLAG set, the keys are registered for. index: ValidatorIndex # Keys being registered. key_material: PQKeyMaterial # Epoch of the last change, or the never-registered marker of the index: # `FAR_FUTURE_EPOCH` for a validator, the deposit epoch for a builder. previous_change_epoch: Epoch ``` ```python class PQKeyPossession(Container): """ The message of a proof of possession: which registrant, and which leanXMSS key. """ # BLS public key of the registrant. pubkey: BLSPubkey # Key whose possession is proven. xmss_public_key: XMSSPublicKey ``` ```python class SignedPQKeyRegistration(Container): """ A registration as it appears in a beacon block. """ # Registration authorized by signature. message: PQKeyRegistration # leanXMSS signature of PQKeyPossession at leaf index 0. possession: XMSSSignature # BLS signature of message by the registrant's signing key. signature: BLSSignature ``` #### New list types ```python class ValidatorPQKeyMaterial(ProgressiveList[PQKeyMaterial]): """ Post-quantum keys of each validator, by validator index. A validator without keys holds the all-zero entry. """ ``` ```python class ValidatorPQKeyChangeEpochs(ProgressiveList[Epoch]): """ Epoch of the last change of each validator's keys, by validator index. `FAR_FUTURE_EPOCH` marks a validator that has never registered keys. """ ``` ```python class BuilderPQKeyMaterial(ProgressiveList[PQKeyMaterial]): """ Post-quantum keys of each builder, by builder index. A builder without keys holds the all-zero entry. """ ``` ```python class BuilderPQKeyChangeEpochs(ProgressiveList[Epoch]): """ Epoch of the last change of each builder's keys, by builder index. The builder's deposit epoch marks a builder that has never registered keys. """ ``` ```python class PQKeyRegistrations(ProgressiveList[SignedPQKeyRegistration]): """ Post-quantum key registrations included in a beacon block. """ ``` The lists are progressive ([EIP-7916](./eip-7916.md)), following the convention of the Gloas and Heze state. #### Modified containers `BeaconBlockBody` gains one field, appended after the existing fields: ```python class BeaconBlockBody(ProgressiveContainer): ACTIVE_FIELDS = active_fields(width=14, gaps=(1, 6)) # [Modified in this EIP] # ... existing fields ... pq_key_registrations: PQKeyRegistrations # [New in this EIP] ``` `BeaconState` gains four fields, appended after the existing fields. The `Validator` and `Builder` containers are unchanged. ```python class BeaconState(ProgressiveContainer): ACTIVE_FIELDS = active_fields(width=50, gaps=(8, 9, 10, 28)) # [Modified in this EIP] # ... existing fields ... post_quantum_key_material: ValidatorPQKeyMaterial # [New in this EIP] post_quantum_key_change_epochs: ValidatorPQKeyChangeEpochs # [New in this EIP] builder_post_quantum_key_material: BuilderPQKeyMaterial # [New in this EIP] builder_post_quantum_key_change_epochs: BuilderPQKeyChangeEpochs # [New in this EIP] ``` The `ACTIVE_FIELDS` of both containers ([EIP-7495](./eip-7495.md)) extend those of Heze by the appended fields. ### Helpers An index with `BUILDER_INDEX_FLAG` set names a builder, as for builder withdrawals ([EIP-7732](./eip-7732.md)). A validator or builder has registered keys if its stored leanXMSS key is not all-zero. ```python def is_pq_public_key_in_use(state: BeaconState, xmss_public_key: XMSSPublicKey) -> bool: """ Return whether any validator or builder holds the leanXMSS key. """ entries = list(state.post_quantum_key_material) + list(state.builder_post_quantum_key_material) return any(entry.xmss_public_key == xmss_public_key for entry in entries) ``` Clients can maintain a map from registered keys to indices, as they do for BLS public keys, so that this check does not scan the registries. ```python def is_valid_pq_possession_proof( state: BeaconState, pubkey: BLSPubkey, xmss_public_key: XMSSPublicKey, proof: XMSSSignature ) -> bool: """ Check that the holder of a leanXMSS key signed it for a BLS public key, at leaf index 0. The domain uses the genesis fork version, so a proof stays valid across forks. """ if xmss_signature_index(proof) != PQ_POSSESSION_XMSS_INDEX: return False possession = PQKeyPossession(pubkey=pubkey, xmss_public_key=xmss_public_key) domain = compute_domain( DOMAIN_PQ_KEY_POSSESSION, GENESIS_FORK_VERSION, state.genesis_validators_root ) return xmss.Verify(xmss_public_key, compute_signing_root(possession, domain), proof) ``` #### Modified `add_validator_to_registry` `add_validator_to_registry` appends an unset entry to `post_quantum_key_material` and `post_quantum_key_change_epochs`, preserving the invariant `len(state.post_quantum_key_material) == len(state.post_quantum_key_change_epochs) == len(state.validators)`: ```python def add_validator_to_registry( state: BeaconState, pubkey: BLSPubkey, withdrawal_credentials: Bytes32, amount: Gwei ) -> None: index = get_index_for_new_validator(state) validator = get_validator_from_deposit(pubkey, withdrawal_credentials, amount) set_or_append_list(state.validators, index, validator) set_or_append_list(state.balances, index, amount) set_or_append_list(state.previous_epoch_participation, index, ParticipationFlags(0b0000_0000)) set_or_append_list(state.current_epoch_participation, index, ParticipationFlags(0b0000_0000)) set_or_append_list(state.inactivity_scores, index, Uint64(0)) set_or_append_list(state.post_quantum_key_material, index, PQKeyMaterial()) # [New in this EIP] set_or_append_list(state.post_quantum_key_change_epochs, index, FAR_FUTURE_EPOCH) # [New in this EIP] ``` `PQKeyMaterial()` is the all-zero entry. #### Modified `add_builder_to_registry` `add_builder_to_registry` writes an unset entry to `builder_post_quantum_key_material` and `builder_post_quantum_key_change_epochs` at the builder's index, including a reused one, preserving the invariant `len(state.builder_post_quantum_key_material) == len(state.builder_post_quantum_key_change_epochs) == len(state.builders)`. A builder's `deposit_epoch` is set here and never modified afterwards, which the never-registered marker relies on: ```python def add_builder_to_registry( state: BeaconState, pubkey: BLSPubkey, version: Uint8, execution_address: ExecutionAddress, amount: Gwei, slot: Slot, ) -> None: index = get_index_for_new_builder(state) builder = Builder( pubkey=pubkey, version=version, execution_address=execution_address, balance=amount, deposit_epoch=compute_epoch_at_slot(slot), withdrawable_epoch=FAR_FUTURE_EPOCH, ) set_or_append_list(state.builders, index, builder) set_or_append_list(state.builder_post_quantum_key_material, index, PQKeyMaterial()) # [New in this EIP] set_or_append_list(state.builder_post_quantum_key_change_epochs, index, builder.deposit_epoch) # [New in this EIP] ``` ### Block processing #### Modified `process_operations` ```python def process_operations(state: BeaconState, body: BeaconBlockBody, parent_slot: Slot) -> None: # ... existing length checks ... assert len(body.pq_key_registrations) <= MAX_PQ_KEY_REGISTRATIONS # [New in this EIP] # ... existing operation processing ... for_ops(body.pq_key_registrations, process_pq_key_registration) # [New in this EIP] ``` #### New `process_pq_key_registration` ```python def process_pq_key_registration( state: BeaconState, signed_registration: SignedPQKeyRegistration ) -> None: registration = signed_registration.message key_material = registration.key_material if is_builder_index(registration.index): index = convert_validator_index_to_builder_index(registration.index) assert index < len(state.builders) # Builders register once active, so after their deposit epoch is finalized assert is_active_builder(state, index) # Builders make no selection proofs assert key_material.vrf_root == VRFRoot() pubkey = state.builders[index].pubkey entries = state.builder_post_quantum_key_material change_epochs = state.builder_post_quantum_key_change_epochs else: index = registration.index assert index < len(state.validators) assert state.validators[index].exit_epoch == FAR_FUTURE_EPOCH pubkey = state.validators[index].pubkey entries = state.post_quantum_key_material change_epochs = state.post_quantum_key_change_epochs # Replay protection and rate limit assert registration.previous_change_epoch == change_epochs[index] if entries[index].xmss_public_key != XMSSPublicKey(): assert get_current_epoch(state) >= change_epochs[index] + MIN_PQ_KEY_CHANGE_DELAY # Authorization by the registrant's BLS signing key domain = compute_domain( DOMAIN_PQ_KEY_REGISTRATION, GENESIS_FORK_VERSION, state.genesis_validators_root ) assert bls.Verify(pubkey, compute_signing_root(registration, domain), signed_registration.signature) # A new leanXMSS key, whose possession is proven assert key_material.xmss_public_key != XMSSPublicKey() assert not is_pq_public_key_in_use(state, key_material.xmss_public_key) assert is_valid_pq_possession_proof( state, pubkey, key_material.xmss_public_key, signed_registration.possession ) entries[index] = key_material change_epochs[index] = get_current_epoch(state) ``` Anyone can submit a registration. Only its BLS signature authorizes it. Registrations are processed last: after the block's other operations and after `block.body.parent_execution_requests`, which `process_parent_execution_payload` applies before `process_operations`. A registration for a registrant whose exit is initiated earlier in the block therefore invalidates the block. Block producers check registrations against the state after the block's other operations. A builder deposit request in `parent_execution_requests` can reuse a builder index, so `process_pq_key_registration` may read a different builder public key than the state before the block holds. A registration for that index in the same block is invalid either way, since the new builder's deposit epoch is the current epoch and the builder is not active. Clients that verify a block's signatures in a batch against the state before the block therefore reach the same result. ### Fork transition At the fork epoch, the upgrade function initializes one unset entry per validator and per builder: ```python def upgrade_to_(pre: heze.BeaconState) -> BeaconState: post = BeaconState( # ... existing fields carried over from `pre` ... post_quantum_key_material=[PQKeyMaterial() for _ in range(len(pre.validators))], # [New in this EIP] post_quantum_key_change_epochs=[FAR_FUTURE_EPOCH for _ in range(len(pre.validators))], # [New in this EIP] builder_post_quantum_key_material=[PQKeyMaterial() for _ in range(len(pre.builders))], # [New in this EIP] builder_post_quantum_key_change_epochs=[builder.deposit_epoch for builder in pre.builders], # [New in this EIP] ) return post ``` ### Gossip A new global topic `pq_key_registration` carries `SignedPQKeyRegistration` messages. The following validations MUST pass before forwarding a `signed_registration` on the network, where `registration = signed_registration.message` and the head state is the node's state at its current head. They are listed in order, cheapest first. - **[IGNORE]** The current epoch, allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`, is at or after the epoch of the fork that activates this EIP. - **[IGNORE]** The head state is at or after the fork that activates this EIP, so that it has the registry lists of this EIP. - **[REJECT]** In the head state, `registration.index` is a validator whose `exit_epoch` is `FAR_FUTURE_EPOCH`, or a builder index, with `BUILDER_INDEX_FLAG` set, of a builder whose `withdrawable_epoch` is `FAR_FUTURE_EPOCH`. - **[IGNORE]** If `registration.index` is a builder index, the builder's `deposit_epoch` is before the epoch of the head state's finalized checkpoint, so that the builder is active. Finality can differ between nodes, so this is not a REJECT. - **[REJECT]** If `registration.index` is a builder index, `registration.key_material.vrf_root` is zero. - **[IGNORE]** `signed_registration` is the first valid registration received for the pair `(registration.index, registration.previous_change_epoch)`. - **[REJECT]** `registration.previous_change_epoch` is the stored change epoch of `registration.index` in the head state. - **[IGNORE]** If the stored leanXMSS key of `registration.index` in the head state is not all-zero, the current epoch, allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`, is at least `registration.previous_change_epoch + MIN_PQ_KEY_CHANGE_DELAY`. - **[REJECT]** The leanXMSS key in `registration.key_material` is not all-zero. - **[REJECT]** The leanXMSS key in `registration.key_material` is not registered for any index in the head state. - **[REJECT]** `signed_registration.signature` is a valid BLS signature of `registration` by the registrant's public key. - **[REJECT]** `signed_registration.possession` is a valid proof of possession of the leanXMSS key for the registrant's BLS public key. A registration that was valid when first received can become invalid before it is included, for example when the same leanXMSS key is registered for another index first. The stored change epoch then does not change, so the first-seen record would block every later registration for the pair. Nodes MAY clear the record for a pair once its first-seen registration is no longer valid against the finalized state. `verify_block_body_operation_limits`, which the `beacon_block` topic applies, gains one check: ```python def verify_block_body_operation_limits(body: BeaconBlockBody) -> None: # ... existing checks ... # [REJECT] The PQ key registration count is within the limit if len(body.pq_key_registrations) > MAX_PQ_KEY_REGISTRATIONS: # [New in this EIP] raise GossipReject("too many pq key registrations") ``` ### Key roles A validator's leanXMSS key is intended to sign every validator message once duties switch to it, and a builder's to sign its execution payload bids and envelopes. A signer MUST NOT sign twice at the same leaf index of a key, and chooses how it allocates leaf indices to messages. At leaf index `PQ_POSSESSION_XMSS_INDEX`, a signer MUST NOT sign anything other than the `PQKeyPossession` of its key for its registrant's BLS public key. A signer MUST use each leanXMSS key in at most one registration, for one registrant, and MUST NOT register a key that it has registered before. The leanXMSS seed and the VRF leaf secrets SHOULD NOT be derived from the registrant's BLS key. Keystores and signer state for leanXMSS keys SHOULD follow [EIP-8310](./eip-8310.md), which extends [ERC-2335](./eip-2335.md). The fork that uses the VRF root defines how its inputs map to indices. ## Rationale ### Registering before switching Dormant keys cannot cause a missed attestation or a slashing, so the registry can ship before the aggregation and networking changes are ready. For the same reason, a change of keys replaces stored bytes and affects no duty. ### Two keys per validator A stateful scheme such as leanXMSS signs at most once per leaf index, and several duties produce more than one message per slot. A second, stateless scheme would avoid leaf management at the cost of a second key and verifier and larger signatures. This EIP registers one leanXMSS key for all messages instead: since the signature carries its leaf index, new duties need no new key. Selection proofs, such as aggregation and sync committee selection proofs, hash unique BLS signatures. leanXMSS signatures are not unique and could be ground, so the VRF root fixes one output per index at registration, before the validator knows its inputs. The root is not checked at registration, which keeps openings out of the registration, so validator clients check their tree before registering it. Builders make no selection proofs, so they register no VRF root. ### Leaf index 0 Leaf index `PQ_POSSESSION_XMSS_INDEX` (`0`) is reserved for the proof of possession. Under a fixed allocation that gives each slot a range of leaf indices, leaf `0` belongs to the genesis slot, which is never a duty slot after this fork, so the proof never takes a leaf from a duty. ### Proof of possession The proof detects faulty key generation at registration rather than at the switch. With key uniqueness, it also prevents copying and squatting (see [Key copying and squatting](#key-copying-and-squatting)). The proof signs `PQKeyPossession` rather than the whole registration, and the BLS signature covers the rest. It names the registrant by BLS public key rather than index: the public key never changes, and it is known before the deposit is processed, so the proof can be produced together with the keystore. Uniqueness covers the registrant's own key, so a change always carries a new leanXMSS key, even to change only the VRF root. This is cheap while the keys are dormant. Consensus does not record earlier keys, so returning to an earlier key or taking a released one is valid on chain, and the signer rules forbid both (see [Leaf reuse](#leaf-reuse)). The proof is 2404 of the 2580 bytes of a registration. It is kept on chain so that every registered key is known to work at the switch. ### Authorization by the signing key The operator who holds the BLS signing key also generates and holds the post-quantum key, so it registers each of its validators without a transaction from the stake owner, whatever the validator's withdrawal credentials. The authorization is a consensus-layer operation only, as for `BLSToExecutionChange`, so this EIP needs no execution-layer change. Allowing changes lets a registrant recover from a lost seed, lost signer state, or a faulty key without exiting. The cost is that an adversary able to forge BLS signatures can replace any registrant's keys, not only those of unregistered ones (see [Quantum adversaries before the switch](#quantum-adversaries-before-the-switch)). A register-only rule would keep keys registered before a break authentic, at the cost of an exit for every lost or faulty key. The two can be combined over time. While a CRQC is believed distant, changes allow recovery. If one appears to be near, a fork can stop changes, or the operation altogether, and keys registered before that fork become the recovery anchor. This protects against a break that is anticipated, not against one that happens before the fork that stops changes. ### Builders Builders sign execution payload bids and envelopes with BLS keys from their own registry ([EIP-7732](./eip-7732.md)), so they need post-quantum keys too. Registering them now gives them keys authorized while BLS is believed secure, without an exit and re-entry at the switch. They use the same operation, naming the builder by its index with `BUILDER_INDEX_FLAG` set, as builder withdrawals do. Builder indices are reused once a builder has withdrawn, so `FAR_FUTURE_EPOCH` cannot mark a builder that has never registered: a first registration signed for an earlier builder at the same index would name it too. A builder's entry instead starts at its deposit epoch. A registration requires an active builder, whose deposit epoch is finalized, so it always moves the stored epoch (see [Builder index reuse](#builder-index-reuse)). ### Changes and replay protection One stored epoch per index, named by each registration and set by each change, provides both replay protection and the rate limit. A per-index counter would prevent replays but not limit changes. `FAR_FUTURE_EPOCH` marks a validator that has never registered, as it marks unset epochs elsewhere in the consensus specifications, and a builder's deposit epoch plays that role for builders. A registration stores the current epoch, and with a delay of at least one epoch every change is applied after the epoch it names, so every applied registration changes the stored epoch and none applies twice. The delay check runs only for an entry that holds a key, whose stored epoch is that of its last change, so it never adds to `FAR_FUTURE_EPOCH`. A registration writes a nonzero key and the current epoch together, and onboarding writes the all-zero key and the marker together, so an entry holds a key exactly when its stored epoch is not its marker. `MIN_PQ_KEY_CHANGE_DELAY` bounds each index to one change per 256 epochs, about 27 hours. Changes and first registrations share `MAX_PQ_KEY_REGISTRATIONS`, and a registration that changes keys again within the delay wastes no block space, since it is invalid. ### Interface instead of construction This EIP specifies only the interface of the primitives and the properties it relies on, as [EIP-4844](./eip-4844.md) does for KZG. The registry does not depend on how the primitives are constructed, so their specification and test vectors can live with the other consensus cryptography. Registry entries carry no version: this EIP accepts one set of primitives. A fork that changes the primitives before the switch defines how existing entries are handled, and introduces new `DomainType` constants for registrations and proofs of possession, so that registrations signed for the earlier primitives cannot be applied. Entries registered before that fork can then be told apart only by their change epoch. ### Lists parallel to the validator and builder registries The keys are stored in lists parallel to `validators` and `builders`. The `Validator` and `Builder` containers and their generalized indices do not change, so light clients and proofs against them are not affected until the switch. ### Registration outside the deposit contract The deposit contract's event layout is fixed, and its events are converted into fixed-size `DepositRequest` records ([EIP-6110](./eip-6110.md)). Neither these nor builder deposit requests have a field for post-quantum keys. Existing validators and builders also need a registration path that does not involve a deposit. A new validator registers through the same path once its deposit has been processed and it has an index, which can be before its activation. A new builder registers once it is active. Setting keys at deposit time requires a new deposit format, which is part of the fork that retires BLS deposits. ### Per-block limits `MAX_PQ_KEY_REGISTRATIONS = 8` bounds the extra block size at about 20 KiB, and the verification cost at eight BLS verifications and eight leanXMSS verifications per block. Gossip rejects a block that exceeds it before the block is processed. At that rate a million validators register in 125,000 full blocks, about 17 days of 12-second slots. ## Backwards Compatibility This EIP requires a hard fork of the consensus layer. It changes no existing duty: every message is still authenticated with BLS, and a registrant that never registers keys is unaffected by this fork. ## Test Cases leanXMSS test vectors for key generation, signing, and verification are in the tests/cryptography/xmss directory of the ethereum/EthLib repository at commit 6390c73ef040311e807b9047d99dca6b1be1c42f. The consensus test vectors cover at least: - A registration for an unregistered validator, and a change of its keys once `MIN_PQ_KEY_CHANGE_DELAY` epochs have passed, including a change that keeps the VRF root. - Rejection of a change before `MIN_PQ_KEY_CHANGE_DELAY` epochs have passed, and of a replayed registration whose `previous_change_epoch` no longer matches. - Rejection of a registration for a validator that has initiated an exit, and for a validator whose exit is initiated earlier in the same block by a proposer slashing, an attester slashing, a voluntary exit, a withdrawal request, or a consolidation request in `parent_execution_requests`. - A registration for an active builder with a zero VRF root, and a later change of its keys. - Rejection of a builder registration with a nonzero VRF root, for a builder that is not yet active, for a builder that has initiated an exit, including by a builder exit request in `parent_execution_requests`, and for a builder index outside the builder registry. - Rejection of a key already registered for another index or for the registrant itself, of an invalid proof of possession, and of a proof of possession at a leaf index other than `PQ_POSSESSION_XMSS_INDEX`. - Rejection of the all-zero leanXMSS key, and an unregistered entry's all-zero key not counting as in use for any other key. - Rejection of a block with two registrations of the same leanXMSS key for two validators, or for a validator and a builder, where the second registration is invalid. - Gossip rejection of a block with more than `MAX_PQ_KEY_REGISTRATIONS` registrations. - A registration, valid on chain, of a key that another validator held and released through a change, including by a builder that shares that validator's BLS public key with the same proof bytes. - After a builder index is reassigned, rejection of a registration signed for the earlier builder at that index, including one signed by the same BLS key after it re-enters at that index. A deposit for a public key still in the builder registry is a top-up, so that key returns to the index only after another builder has held it in between. - The fork upgrade, which sets each builder's change epoch to its deposit epoch, `add_builder_to_registry` on a reused index, and the length invariants stated for `add_validator_to_registry` and `add_builder_to_registry`. ## Security Considerations ### Quantum adversaries before the switch A registration is only as authentic as the BLS signature that authorizes it. Once a CRQC exists, an adversary can register or replace the keys of any registrant, at most once every `MIN_PQ_KEY_CHANGE_DELAY` epochs per index. The switch therefore has to happen while BLS is believed secure, or a fork has to stop key changes before a break, so that the keys registered until then can be trusted (see [Authorization by the signing key](#authorization-by-the-signing-key)). ### Leaf reuse Each leaf index signs at most once, whichever allocation the signer uses: slashing protection enforces it under a fixed per-slot allocation, and the signer's state under free allocation. Each key is used in one registration, so leaf `PQ_POSSESSION_XMSS_INDEX` signs one proof, and a registration whose block is orphaned is included again with the same bytes. Consensus does not track released keys and accepts one again. Returning to an earlier key needs a second proof or the stored first one. Taking a key that another registrant released needs a proof of a different message at leaf `PQ_POSSESSION_XMSS_INDEX`, unless the two registrants share a BLS public key, in which case the released key's stored proof applies. The signer rules forbid all of these. ### Key copying and squatting The registry rejects a key that any registrant has already registered. Without that rule, a validator could register another's key and claim its signatures as its own participation, since the signed messages do not name the validator. The proof of possession signs the registrant's BLS public key, so a copied key, even one published but not yet registered, cannot be registered for another registrant. A validator and a builder may share a BLS public key, since they enter through different deposit paths. Their proofs of possession for a key are then the same message, so uniqueness keeps one leanXMSS key from serving both at once, and only the signer rules keep it from moving between them. This binding relies on BLS public keys never changing: if a later fork made them mutable, a key bound to an outdated public key would need a second proof at leaf `0`, which is a leaf reuse. ### Readiness is not registration A registered key proves that its holder could sign once, at registration time. It does not prove that the validator will run post-quantum software, keep its seed, or keep its signer state consistent at the switch. The effective balance of active validators with registered keys is therefore an upper bound on readiness, so the switch needs a margin above the two-thirds finality threshold. ### VRF tree The registry accepts a VRF root that another validator has registered. A validator that copies a root cannot open it before its owner does, but it has the same outputs and can replay the owner's openings once they are published. The fork that uses the tree has to account for this. ### Builder index reuse A builder index is reassigned once its builder has exited and withdrawn. `add_builder_to_registry` then resets the entry, releasing the earlier builder's key and starting the change epoch at the new builder's deposit epoch, which never changes afterwards. The earlier builder's last change epoch is at most its exit epoch, and the index can be reassigned only `MIN_BUILDER_WITHDRAWABILITY_DELAY` epochs after that exit. With that delay at least `1`, every registration signed for an earlier builder at that index names an epoch before the new builder's deposit epoch, so none of them applies. This includes the same BLS key re-entering at that index, which can only happen after another builder has held it in between, since a deposit for a public key still in the builder registry is a top-up. ### Denial of service and state growth Each registration costs one BLS and one leanXMSS verification, and the registry grows with the validator and builder sets: - **Blocks.** At most `MAX_PQ_KEY_REGISTRATIONS` registrations per block; see [Per-block limits](#per-block-limits). - **Gossip.** Registrations are first-seen per index and previous change epoch, and changes are rate-limited per index (see [Changes and replay protection](#changes-and-replay-protection)). - **State.** Each validator and builder adds 72 bytes: 64 bytes of keys and an 8-byte change epoch, about 72 MB for a million validators. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).