This EIP introduces a preregistration mechanism that allows a validator key holder to commit to specific withdrawal credentials before any deposit is made, addressing a known deposit front-running vulnerability in delegated staking. Preregistrations are submitted through a new EIP-7685 request type via a system contract on the execution layer, rate-limited by an EIP-1559-style exponential fee mechanism. The consensus layer stores the preregistration in beacon state and enforces it at deposit processing time: matching deposits proceed and the preregistration is consumed; mismatched deposits are silently rejected. The mechanism is fully optional — deposits for public keys without a preregistration are processed exactly as today.
In delegated staking, the entity funding a validator and the entity generating the BLS keypair are distinct parties. This separation is common across liquid staking protocols, staking-as-a-service providers, and any arrangement where one party provides capital and another operates the validator. The BLS key is the validator's identity: the first deposit for a given pubkey establishes the withdrawal credentials permanently under first-deposit-wins semantics. The key holder can therefore submit a deposit with attacker-controlled withdrawal credentials before the funding party's deposit is processed. The funding party has no on-chain guarantee that the withdrawal credentials it intends will be honored.
At least a third of all staked ETH flows through delegated architectures with identifiable protocol-level protections against this attack. Since no on-chain mechanism exists to bind a public key to withdrawal credentials before the first deposit, every affected product has independently built application-layer defenses — bond-based pre-deposit schemes, guardian committees, or both. These defenses work (no known exploits have occurred in production), but each one is built, audited, and maintained independently, and new entrants are vulnerable by default until they do the same.
Preregistration addresses this at the protocol layer: the key holder signs a binding commitment to specific withdrawal credentials before any deposit is made, and the consensus layer enforces this commitment at deposit processing time.
For an extended analysis of the problem scope, existing application-layer defenses, and data sources, see the discussion thread linked in the header.
| Name | Value | Comment |
|---|---|---|
VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS |
TBD |
Where to call and store relevant details about preregistration mechanism |
VALIDATOR_PREREGISTRATION_REQUEST_TYPE |
TBD |
The EIP-7685 type prefix for preregistration request |
SYSTEM_ADDRESS |
0xfffffffffffffffffffffffffffffffffffffffe |
Address used to invoke system operation on contract |
EXCESS_VALIDATOR_PREREGISTRATION_REQUESTS_STORAGE_SLOT |
0 |
|
VALIDATOR_PREREGISTRATION_REQUEST_COUNT_STORAGE_SLOT |
1 |
|
VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT |
2 |
Pointer to head of the preregistration request message queue |
VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT |
3 |
Pointer to the tail of the preregistration request message queue |
VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_STORAGE_OFFSET |
4 |
The start memory slot of the in-state preregistration request message queue |
MAX_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK |
4 |
Maximum number of preregistration requests that can be dequeued into a block |
TARGET_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK |
1 |
|
MIN_VALIDATOR_PREREGISTRATION_REQUEST_FEE |
1 |
|
VALIDATOR_PREREGISTRATION_REQUEST_FEE_UPDATE_FRACTION |
17 |
|
EXCESS_INHIBITOR |
2**256-1 |
Excess value used to compute the fee before the first system call |
| Name | Value | Comment |
|---|---|---|
DOMAIN_VALIDATOR_PREREGISTRATION |
DomainType('0x0E000000') |
Uses GENESIS_FORK_VERSION (stable across forks) |
MAX_VALIDATOR_PREREGISTRATION_REQUESTS_PER_PAYLOAD |
uint64(2**2) (= 4) |
Maximum preregistration requests per execution payload |
VALIDATOR_PREREGISTRATIONS_LIMIT |
uint64(2**19) (= 524,288) |
Maximum stored preregistrations in beacon state |
VALIDATOR_PREREGISTRATION_EXPIRY_SLOTS |
uint64(2**18) (= 262,144) |
Lifetime in finalized slots (~36 days at 12s/slot) |
FORK_BLOCK -- the first block in a blockchain after this EIP has been activated.The new preregistration request is an EIP-7685 request with type VALIDATOR_PREREGISTRATION_REQUEST_TYPE and consists of the following fields:
source_address: Bytes20pubkey: Bytes48withdrawal_credentials: Bytes32signature: Bytes96The EIP-7685 encoding of a preregistration request is computed as follows.
request_type = VALIDATOR_PREREGISTRATION_REQUEST_TYPE
request_data = read_preregistration_requests()
The contract has three different code paths, which can be summarized at a high level as follows:
176 byte input, the validator's public key concatenated with withdrawal credentials and a BLS signature.If call data input to the contract is exactly 176 bytes, perform the following:
check_fee())1 for the current block (increment_count())insert_preregistration_request_into_queue())Specifically, the functionality is defined in pseudocode as the function add_preregistration_request():
def add_preregistration_request(Bytes48: pubkey, Bytes32: withdrawal_credentials,
Bytes96: signature):
"""
Add preregistration request adds new request to the preregistration request queue,
so long as a sufficient fee is provided.
"""
# Verify sufficient fee was provided.
fee = get_fee()
require(msg.value >= fee, 'Insufficient value for fee')
# Increment preregistration request count.
count = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_COUNT_STORAGE_SLOT)
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_COUNT_STORAGE_SLOT, count + 1)
# Insert into queue.
queue_tail_index = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT)
queue_storage_slot = VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_STORAGE_OFFSET + queue_tail_index * 7
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot, msg.sender)
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1, pubkey[0:32])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2, pubkey[32:48] ++ withdrawal_credentials[0:16])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 3, withdrawal_credentials[16:32] ++ signature[0:16])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 4, signature[16:48])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 5, signature[48:80])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 6, signature[80:96])
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT, queue_tail_index + 1)
The following pseudocode can compute the cost of an individual preregistration request, given a certain number of excess preregistration requests.
def get_fee() -> int:
excess = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_VALIDATOR_PREREGISTRATION_REQUESTS_STORAGE_SLOT)
require(excess != EXCESS_INHIBITOR, 'Inhibitor still active')
return fake_exponential(
MIN_VALIDATOR_PREREGISTRATION_REQUEST_FEE,
excess,
VALIDATOR_PREREGISTRATION_REQUEST_FEE_UPDATE_FRACTION
)
def fake_exponential(factor: int, numerator: int, denominator: int) -> int:
i = 1
output = 0
numerator_accum = factor * denominator
while numerator_accum > 0:
output += numerator_accum
numerator_accum = (numerator_accum * numerator) // (denominator * i)
i += 1
return output // denominator
When the input to the contract is length zero, interpret this as a get request for the current fee, i.e. the contract returns the result of get_fee().
At the end of processing any execution block starting from the FORK_BLOCK (i.e. after processing all transactions and after performing the block body preregistration requests validations), call VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS as SYSTEM_ADDRESS with no calldata. The invocation triggers the following:
dequeue_preregistration_requests())update_excess_preregistration_requests())0 (reset_preregistration_requests_count())Each preregistration request must appear in the EIP-7685 requests list in the exact order returned by dequeue_preregistration_requests().
Additionally, the system call and the processing of that block must conform to the following:
30_000_000.VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, the corresponding block MUST be marked invalid.The functionality triggered by the system call is defined in pseudocode as the function read_preregistration_requests():
###################
# Public function #
###################
def read_preregistration_requests():
reqs = dequeue_preregistration_requests()
update_excess_preregistration_requests()
reset_preregistration_requests_count()
return ssz.serialize(reqs)
###########
# Helpers #
###########
class PreregistrationRequest(object):
source_address: Bytes20
pubkey: Bytes48
withdrawal_credentials: Bytes32
signature: Bytes96
def dequeue_preregistration_requests():
queue_head_index = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT)
queue_tail_index = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT)
num_in_queue = queue_tail_index - queue_head_index
num_dequeued = min(num_in_queue, MAX_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK)
reqs = []
for i in range(num_dequeued):
queue_storage_slot = VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_STORAGE_OFFSET + (queue_head_index + i) * 7
source_address = address(sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot)[0:20])
pubkey = (
sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1)[0:32]
+ sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[0:16]
)
withdrawal_credentials = (
sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[16:32]
+ sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 3)[0:16]
)
signature = (
sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 3)[16:32]
+ sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 4)[0:32]
+ sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 5)[0:32]
+ sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 6)[0:16]
)
req = PreregistrationRequest(
source_address=Bytes20(source_address),
pubkey=Bytes48(pubkey),
withdrawal_credentials=Bytes32(withdrawal_credentials),
signature=Bytes96(signature)
)
reqs.append(req)
new_queue_head_index = queue_head_index + num_dequeued
if new_queue_head_index == queue_tail_index:
# Queue is empty, reset queue pointers
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT, 0)
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT, 0)
else:
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT, new_queue_head_index)
return reqs
def update_excess_preregistration_requests():
previous_excess = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_VALIDATOR_PREREGISTRATION_REQUESTS_STORAGE_SLOT)
# Check if excess needs to be reset to 0 for first iteration after activation
if previous_excess == EXCESS_INHIBITOR:
previous_excess = 0
count = sload(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_COUNT_STORAGE_SLOT)
new_excess = 0
if previous_excess + count > TARGET_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK:
new_excess = previous_excess + count - TARGET_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_VALIDATOR_PREREGISTRATION_REQUESTS_STORAGE_SLOT, new_excess)
def reset_preregistration_requests_count():
sstore(VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, VALIDATOR_PREREGISTRATION_REQUEST_COUNT_STORAGE_SLOT, 0)
TBD — the runtime bytecode will be provided with the reference implementation.
TBD — the deterministic deployment transaction will be provided with the reference implementation.
A sketch of the key consensus layer changes is included below.
class ValidatorPreregistration(Container):
pubkey: BLSPubkey # 48 bytes — the validator key being registered
withdrawal_credentials: Bytes32 # 32 bytes — withdrawal credentials to lock in
class PreregistrationRequest(Container):
source_address: ExecutionAddress # 20 bytes — msg.sender of the system contract call
pubkey: BLSPubkey # 48 bytes
withdrawal_credentials: Bytes32 # 32 bytes
signature: BLSSignature # 96 bytes — BLS proof of key ownership
class StoredPreregistration(Container):
pubkey: BLSPubkey
withdrawal_credentials: Bytes32
inclusion_slot: Slot # CL beacon slot when stored; used for expiry and Merkle provability
ExecutionRequests is extended with a new field:
class ExecutionRequests(Container):
deposits: List[DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD]
withdrawals: List[WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD]
consolidations: List[ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD]
preregistrations: List[PreregistrationRequest, MAX_VALIDATOR_PREREGISTRATION_REQUESTS_PER_PAYLOAD] # [New]
Preregistration requests are processed after deposit requests. The processing order is defined by explicit for_ops() calls in process_operations, following the same pattern as deposits, withdrawals, and consolidations:
# In process_operations (sketch):
for_ops(body.execution_requests.deposits, process_deposit_request)
for_ops(body.execution_requests.withdrawals, process_withdrawal_request)
for_ops(body.execution_requests.consolidations, process_consolidation_request)
for_ops(body.execution_requests.preregistrations, process_preregistration_request) # [New]
BeaconState is extended with a new field:
class BeaconState(Container):
# ... existing fields ...
validator_preregistrations: List[StoredPreregistration, VALIDATOR_PREREGISTRATIONS_LIMIT] # [New]
def get_stored_preregistration(state: BeaconState, pubkey: BLSPubkey) -> Optional[StoredPreregistration]:
for pre_reg in state.validator_preregistrations:
if pre_reg.pubkey == pubkey:
return pre_reg
return None
def remove_stored_preregistration(state: BeaconState, pubkey: BLSPubkey) -> None:
state.validator_preregistrations = [
pre_reg for pre_reg in state.validator_preregistrations
if pre_reg.pubkey != pubkey
]
Note: Clients SHOULD maintain a secondary index by pubkey for efficient lookup rather than performing a linear scan.
def process_preregistration_request(
state: BeaconState,
preregistration_request: PreregistrationRequest,
) -> None:
pubkey = preregistration_request.pubkey
withdrawal_credentials = preregistration_request.withdrawal_credentials
signature = preregistration_request.signature
# Reject if a preregistration already exists for this pubkey
if get_stored_preregistration(state, pubkey) is not None:
return
# Reject if a validator with this pubkey already exists
if pubkey in [v.pubkey for v in state.validators]:
return
# Reject if this pubkey already has a valid pending deposit
if is_pending_validator(state, pubkey):
return
# Reject if state list is at capacity
if len(state.validator_preregistrations) >= VALIDATOR_PREREGISTRATIONS_LIMIT:
return
# Verify BLS signature: proof of key ownership.
# Domain uses GENESIS_FORK_VERSION (fork_version=None default) + genesis_validators_root.
preregistration = ValidatorPreregistration(
pubkey=pubkey,
withdrawal_credentials=withdrawal_credentials,
)
domain = compute_domain(
DOMAIN_VALIDATOR_PREREGISTRATION,
genesis_validators_root=state.genesis_validators_root,
)
signing_root = compute_signing_root(preregistration, domain)
if not bls.Verify(pubkey, signing_root, signature):
return
state.validator_preregistrations.append(StoredPreregistration(
pubkey=pubkey,
withdrawal_credentials=withdrawal_credentials,
inclusion_slot=state.slot,
))
process_deposit_request is modified to enforce preregistration constraints. The preregistration check MUST precede any builder routing logic (e.g. EIP-7732) to prevent bypass via builder withdrawal credentials. When a preregistration exists, the deposit is accepted only if both the withdrawal credentials match and the deposit BLS signature is valid. This early BLS check prevents an attacker from consuming a preregistration with an invalid-signature deposit (see Security Considerations).
def process_deposit_request(
state: BeaconState,
deposit_request: DepositRequest,
) -> None:
# Preregistration check — MUST precede builder routing.
pre_reg = get_stored_preregistration(state, deposit_request.pubkey)
if pre_reg is not None:
if deposit_request.withdrawal_credentials != pre_reg.withdrawal_credentials:
return # Withdrawal credentials mismatch: reject
if not is_valid_deposit_signature(
deposit_request.pubkey,
deposit_request.withdrawal_credentials,
deposit_request.amount,
deposit_request.signature,
):
return # Invalid BLS sig: reject, preserve preregistration
remove_stored_preregistration(state, deposit_request.pubkey)
# ... [Gloas builder routing logic, if applicable] ...
state.pending_deposits.append(PendingDeposit(
pubkey=deposit_request.pubkey,
withdrawal_credentials=deposit_request.withdrawal_credentials,
amount=deposit_request.amount,
signature=deposit_request.signature,
slot=state.slot,
))
The key invariant: deposits in pending_deposits cannot have an active preregistration. Either the preregistration was consumed or the deposit was rejected. This means apply_pending_deposit requires no modifications — it operates on deposits that have already passed the preregistration check.
def process_validator_preregistration_expiry(state: BeaconState) -> None:
finalized_slot = compute_start_slot_at_epoch(state.finalized_checkpoint.epoch)
state.validator_preregistrations = [
pre_reg for pre_reg in state.validator_preregistrations
if finalized_slot < Slot(pre_reg.inclusion_slot + VALIDATOR_PREREGISTRATION_EXPIRY_SLOTS)
]
Called once per epoch during process_epoch.
Invalid deposits are already silently ignored by the consensus layer if the signature is invalid. This EIP adds one optional validation condition: if a preregistration exists for a pubkey, only deposits with matching withdrawal credentials and a valid BLS signature are accepted. Deposits with mismatched credentials or invalid signatures are treated as invalid and ignored. The check only applies to pubkeys that have been preregistered — all other deposits are processed as before.
Without early BLS verification, an attacker could consume a preregistration by depositing with matching withdrawal credentials but an invalid signature, stripping protection from the pubkey. This is not a new pattern — EIP-7732 already verifies deposit BLS signatures at block time for builder routing. Preregistration applies the same approach to the narrow subset of deposits targeting preregistered pubkeys.
When a deposit's withdrawal credentials do not match a preregistration, the deposit is silently rejected — the ETH remains in the Deposit Contract, which has no withdrawal function. This is a deliberate design choice: returning mismatched deposits would allow an attacker to front-run at no cost (deposit, get rejected, recover funds, repeat). Permanent loss makes front-running economically self-defeating.
The risk of accidental mismatch is low in practice. Preregistration requires a dedicated BLS signing step separate from deposit data. Staking protocols verify the preregistration on-chain via EIP-4788 before submitting deposits. Preregistrations expire automatically after ~36 days if unused, freeing beacon state. However, because the signed message is publicly visible on-chain, anyone can replay it to re-establish the same binding after expiry. Operators should treat preregistration signing as a permanent, irrevocable decision for that key.
CL gossip has no economic cost — generating BLS keypairs is cheap (~2ms), and verification cost (~1.5ms per message) falls on every node, creating an asymmetry favoring an attacker. The EL system contract solves this with an EIP-1559-style fee that starts at 1 wei but increases exponentially with demand, making sustained spam prohibitively expensive. Staking protocols can call the system contract directly from their own contracts.
Preregistration expiry is computed against finalized_slot rather than the current slot to prevent a race condition during extended non-finality (inactivity leak). During such periods, EIP-6110 freezes pending deposits. If preregistration expiry used the current slot, a preregistration could expire while the staking protocol's deposit transaction has not yet been included — silently removing the withdrawal credentials protection without any action by either party. Using finalized_slot keeps preregistrations and deposits in lockstep: neither advances while finality is stalled.
DOMAIN_VALIDATOR_PREREGISTRATION uses GENESIS_FORK_VERSION (the default when no fork_version is passed to compute_domain), so a preregistration signed once is valid across all past and future forks. This allows signing on air-gapped hardware without knowledge of the current fork version. Chain separation is achieved via genesis_validators_root, which is an immutable per-chain constant.
A dedicated domain is used rather than reusing DOMAIN_DEPOSIT because DOMAIN_DEPOSIT does not include genesis_validators_root, meaning deposit-domain signatures can be replayed across chains.
source_address fieldsource_address is included for EIP-7685 framework consistency. It is not used in CL validation — the BLS signature is the sole authorization mechanism. Any address may submit a preregistration on behalf of a key holder.
BLS verification happens on the CL, not in the EL system contract. The system contract acts as a rate-limited queue; the CL performs semantic validation. EL verification would require EIP-2537 BLS precompiles and cost upwards of 150,000 gas per preregistration (hash-to-curve + pairing check), unnecessarily increasing cost. Invalid signatures are silently discarded by the CL, costing only the system contract fee.
TARGET_VALIDATOR_PREREGISTRATION_REQUESTS_PER_BLOCK = 1 keeps the baseline fee minimal for an infrequent operation, with burst capacity up to 4 per block.
VALIDATOR_PREREGISTRATIONS_LIMIT = 524,288 (2^19) is chosen so that the list cannot saturate at the target rate. At 1 preregistration per block, filling 2^19 entries takes ~72.8 days — well beyond the 2^18-slot (~36-day) expiry window. In steady state at target rate, the list stabilizes around 2^18 entries (~50% capacity) as new arrivals are balanced by expiring entries. Sustained saturation requires an average rate above target, which causes excess to accumulate and the fee to grow exponentially — making prolonged spam economically prohibitive.
During extended non-finality the finalized checkpoint stalls and expiry pauses, so the list may eventually fill; this is an acceptable liveness degradation for the optional preregistration path, not a safety issue, and the backlog clears once finality resumes. Worst-case state size is ~46 MB (88 bytes × 524,288 entries).
This EIP protects new validator creation; EIP-7684 protects top-ups to existing validators by returning mismatched deposits minus a penalty. Without preregistration, EIP-7684 alone turns theft into griefing (the rogue validator still exists). Without EIP-7684, preregistration does not cover mismatched top-ups. The two are orthogonal and could ship in the same fork.
Preregistration is fully optional. Protocols that already have their own defenses can adopt it at their own pace, or not at all. For protocols that do adopt it, the intended flow is: the operator signs a preregistration, the protocol submits it to the system contract, waits for finalization (~12.8 minutes), verifies it on-chain via EIP-4788 Merkle proof, and submits the deposit. The verification and deposit steps can be combined into a single transaction using a shared verification contract — not part of this specification, but a reference implementation will be provided separately.
This EIP introduces backward-incompatible changes to the block structure and validation rules on both the consensus and execution layers, and must be scheduled with a hard fork.
Execution layer: a new system contract is deployed and a new EIP-7685 request type is introduced.
Consensus layer: the ExecutionRequests container and BeaconState are extended with new fields.
TBD — test vectors will be provided with the reference implementation.
TBD
Preregistration is fully optional. Without a preregistration for a given pubkey, deposits are processed exactly as today — the first-deposit-wins rule applies unchanged. Existing validators, top-up deposits, and non-delegated staking are entirely unaffected.
Non-delegated staking (solo stakers, exchanges, institutions) controls both funds and keys, so the front-running vulnerability does not apply. These stakers may optionally preregister, but gain no benefit from doing so.
Bond-based protocols that verify withdrawal credentials via EIP-4788 after a pre-deposit can adopt preregistration to skip the verification delay and avoid locking capital in the entry queue. Their existing flow continues to work without changes.
Guardian-committee protocols that rely on depositRoot snapshot signing would need to update their deposit flow. Preregistration replaces the guardian infrastructure with a protocol-level guarantee, eliminating depositRoot rotation attacks and committee liveness dependencies.
The preregistration signature is fork-agnostic and does not expire cryptographically — only the on-chain record has a validity window (~36 days). Anyone who obtains a signed preregistration message can resubmit it at any time to re-establish the on-chain record after expiry. Once an operator signs a preregistration binding a pubkey to specific withdrawal credentials, that commitment is effectively permanent: the signed message can always be replayed. Operators must treat preregistration signing as an irrevocable decision for that key.
If an attacker deposits for a pubkey before the preregistration is processed, the deposit creates a validator under first-deposit-wins and the preregistration is rejected. If both arrive in the same block, deposits are processed before preregistrations (see processing order in process_operations), so the same outcome applies. Staking protocols detect this via EIP-4788: they MUST verify the preregistration is finalized and present in beacon state before depositing.
An adversary could attempt to strip preregistration protection by depositing with matching withdrawal credentials but an invalid BLS signature. This EIP prevents this attack by verifying the deposit's BLS signature in process_deposit_request before consuming the preregistration. Only deposits with both matching withdrawal credentials and a valid BLS signature can consume a preregistration. Invalid-signature deposits are silently rejected without affecting the preregistration.
When EIP-7732 (ePBS) builder routing is active, deposits with 0x03 (builder) withdrawal credentials are routed directly to the builder registry, bypassing the standard pending deposit flow. The preregistration withdrawal credentials check must precede builder routing in process_deposit_request to prevent bypass.
The EIP-1559-style fee mechanism is the primary spam defense. Every submission in a block increments count, and at block end excess updates as excess += count − target (floored at zero). The fee grows as e^(excess/17) wei, so a high-volume block raises the fee immediately for all subsequent requests. For example, at a sustained rate of n submissions per block, excess grows by n − 1 per block and the fee exceeds 1 ETH after roughly 17 × ln(10^18) / (n − 1) blocks.
Under normal operation, the state list stays near zero (preregistrations consumed within hours). The 524,288 entry limit (~46 MB) is sized so that saturation requires a sustained rate above target — and therefore an exponentially growing fee. During extended non-finality, the list may fill as expiry pauses; this degrades liveness for the optional preregistration path but is not a safety issue.
The 262,144-slot validity window (~36 days) gives ample time for inclusion.
The system contract does not refund excess fee payment. Callers should query the current fee via the fee getter (empty calldata) before submitting. This is the same behavior as EIP-7002.
If the system call to the preregistration contract fails for any reason, the block MUST be deemed invalid. This is the same consideration as in EIP-7002.
If there is no code at VALIDATOR_PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS, the block MUST be deemed invalid. This is the same consideration as in EIP-7002.
Copyright and related rights waived via CC0.