EIP-8205 Withdrawal credentials pre-registration (Withdrawal credentials preregistration)

Hegota Declined for Inclusion · Consensus & staking · EIP status Draft · created 2026-03-26

Adds a mechanism for the holder of a validator key to bind withdrawal credentials ahead of the deposit. The consensus layer checks this when processing the deposit, which prevents an attack in delegated staking where someone else deposits first and hijacks the withdrawal address.

Why it is needed

On Ethereum, a validator's identity is its BLS key. The first deposit that arrives for a given public key permanently sets that validator's withdrawal credentials. This is not a problem when one person holds both the funds and the key, as with a solo staker. But in delegated staking, such as liquid staking or staking-as-a-service, where the party providing the funds differs from the party creating the key, the operator holding the key can submit a deposit with their own withdrawal address before the funder's deposit. The funder gets no onchain guarantee that their desired withdrawal credentials will be honored. The source states that at least one third of staked ETH goes through such delegation structures. Today each protocol builds, audits, and maintains its own defenses, such as collateral-based pre-deposits or guardian committees. There is no known actual loss, but new entrants are vulnerable by default until they put the same defenses in place.

What changes

Today

After EIP-8205

Who is affected

In depth

Execution layer request format and system contract

A pre-registration request uses the EIP-7685 request type PREREGISTRATION_REQUEST_TYPE (value TBD). It is a 176-byte record formed by concatenating three fields: pubkey (Bytes48), withdrawal_credentials (Bytes32) and signature (Bytes96). request_data is the dequeued records concatenated flat in queue order. The contract address PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS is still TBD. The contract has three paths. When SYSTEM_ADDRESS (0xfffffffffffffffffffffffffffffffffffffffe) calls it, it dequeues this block's share of requests. When any other caller sends 176 bytes, add_preregistration_request() checks the fee (check_fee), increments the block count by 1, enqueues the request, and then emits pubkey ++ withdrawal_credentials ++ signature as-is in a topic-less anonymous log (LOG0). An input of length 0 is a lookup that returns the current fee, and it must revert if value is sent. Every other length reverts. The queue uses 6 storage slots per request. Storage slots 0 to 3 hold excess, count, queue head and queue tail, and the queue body starts at slot 4. The per-block dequeue maximum is MAX_PREREGISTRATION_REQUESTS_PER_BLOCK = 4 and the target is TARGET_PREREGISTRATION_REQUESTS_PER_BLOCK = 1. The maximum limits only how many are dequeued, not how many submissions the contract accepts within a single block.

Fee calculation and queue disabling path

The fee works the same way as in EIP-7002 and EIP-7251. get_fee() reads only the stored excess value and computes fake_exponential(MIN_PREREGISTRATION_REQUEST_FEE, excess, PREREGISTRATION_REQUEST_FEE_UPDATE_FRACTION). The minimum fee is 1 wei, the update fraction is 17, and the fee is roughly e**(excess / 17) wei. The end-of-block system call updates excess to max(0, previous_excess + count - TARGET_PREREGISTRATION_REQUESTS_PER_BLOCK), so submissions in block N affect the fee starting from block N + 1. All submissions within the same block pay the same minimum fee. Overpayment is not refunded, so the fee should be queried with empty calldata before submitting. If the excess slot holds EXCESS_INHIBITOR (2**256-1), get_fee() reverts and requests are blocked. The first system call after activation resets this value to 0. If a system call arrives with non-empty calldata, it dequeues requests and then stores EXCESS_INHIBITOR again, disabling the queue. This EIP always makes system calls with empty calldata, so this path is unreachable and is left for a future upgrade that retires or halts the contract. Without this mechanism, even after the protocol stops the system call, the contract would keep accepting fees and requests, which would pile up in a queue that is never drained. The reference implementation of the EIP-8282 builder request contract uses the same approach.

End-of-block system call rules

From FORK_BLOCK, at the end of processing every execution block, SYSTEM_ADDRESS calls PREREGISTRATION_REQUEST_PREDEPLOY_ADDRESS with empty calldata. This call dequeues requests from the queue with dequeue_preregistration_requests() and resets head and tail to 0 when the queue becomes empty. It updates excess with update_excess_preregistration_requests() and resets count to 0 with reset_preregistration_requests_count(). Dequeued requests must go into the EIP-7685 request list in exactly the order they were returned. The system call has a dedicated gas limit of 30_000_000, and the gas it uses is not added to the block gas usage and is excluded from the block gas limit check. It does not follow EIP-1559 fee burn semantics, and no value is transferred. If there is no code at the address, or the call fails or returns an error, the block must be treated as invalid. These conditions are the same considerations as in EIP-7002.

Consensus layer storage structure and pre-registration processing

The consensus layer gains new containers: PreregistrationRequest(pubkey, withdrawal_credentials, signature), ValidatorPreregistration(pubkey, withdrawal_credentials) as the signing target, and StoredPreregistration(pubkey, withdrawal_credentials, expiry_slot) for storage. A preregistrations field is added to ExecutionRequests and a validator_preregistrations field is added to BeaconState. The number of requests per payload cannot exceed MAX_PREREGISTRATION_REQUESTS_PER_PAYLOAD = 4. Under EIP-7732, requests are applied in parent_execution_requests after deposit requests. process_preregistration_request() silently rejects when an active registration already exists for the same pubkey, when a validator with that pubkey already exists, when there is a valid pending deposit for which is_pending_validator() is true, or when the number of active registrations has reached PREREGISTRATIONS_LIMIT (2**19 = 524,288). If the request passes, it builds a domain from DOMAIN_PREREGISTRATION (0x11000000) and genesis_validators_root and verifies the BLS signature. This domain uses GENESIS_FORK_VERSION, so a single signature is valid across all past and future forks, and genesis_validators_root distinguishes the chain. DOMAIN_DEPOSIT is not reused because that domain has no genesis_validators_root, which would allow replay across chains. Once verification passes, it stores expiry_slot = state.slot + PREREGISTRATION_EXPIRY_SLOTS (2**18 = 262,144 slots), and if an expired record already exists, it overwrites it in place. Clients are advised to keep a secondary index by pubkey instead of using a linear scan.

Deposit processing changes and permanent rejection of mismatched deposits

process_deposit_request() from EIP-6110 changes. The check applies only when the deposit's pubkey has an active registration. If the withdrawal credentials differ from the registration, the deposit is silently rejected. Even if the credentials match, the deposit is rejected when is_valid_deposit_signature() is false, and the registration is left in place. If both checks pass, remove_stored_preregistration() deletes the registration and a PendingDeposit is added. A valid new-validator pending deposit and an active registration cannot coexist for the same pubkey. This is the core invariant. A PendingDeposit with an invalid signature can coexist if it entered the queue before the registration, but it is ignored in normal processing, and apply_pending_deposit does not need to change. Builder deposits use a separate request type and process_builder_deposit_request, so this check does not apply to them. The ETH from a rejected deposit stays in the deposit contract, which has no withdrawal function, and is permanently lost. The source describes this as intended design. If mismatched deposits were returned, an attacker could repeat deposit, rejection and recovery at no cost, so the permanent loss is what makes front-running economically self-defeating. The source notes that the risk of an accidental mismatch is low, because pre-registration goes through a separate BLS signing step apart from the deposit data, and the protocol confirms the registration through EIP-4788 before depositing.

Expiry determination and EIP-7732 payload slot

is_active_preregistration() compares whether state.latest_execution_payload_bid.slot is less than the stored expiry_slot. It uses the slot of the outstanding parent bid rather than the slot at which the request is applied, because in EIP-7732 a payload's requests are applied one block later. This way the deposit is checked against the registrations that were active at the slot where that payload was built. The comparison is a strict inequality, so a payload at a slot equal to expiry_slot is no longer protected. process_preregistration_expiry() removes expired records during epoch processing, but it is pure garbage collection. Its timing has no effect on whether a registration is enforced or whether a new request is accepted, and an expired record stays until the next cleanup and can be overwritten by a new registration after passing the active capacity check. The absolute expiry slot is computed once at storage time, so later changes to PREREGISTRATION_EXPIRY_SLOTS do not alter existing deadlines. It also lets the execution layer verify the same window without knowing the lifetime parameter, the slot time or SLOTS_PER_EPOCH. If a verification contract proves expiry_slot via EIP-4788 and requires the current EIP-7843 SLOTNUM to be less than it, the deposit is atomically protected or reverted. Expiry does not depend on finality, so it proceeds even while finality is stalled. A registration that expired unused can be re-established by resubmitting the same signed message.

Security considerations and parameter rationale

Signature permanence: The preregistration signature is fork-independent and never expires cryptographically. Only the onchain record has a window of 262,144 slots (about 36 days at 12-second slots), and anyone holding the signed message can resubmit it after expiry to revive the record. Operators must confirm that the credential they choose is supported in the intended withdrawal flow. Builder deposits: Preregistration applies only to validator deposits, and EIP-8282 builder deposits neither check nor consume it. Preregistering a 0xB0 credential does not register or protect a builder, and sending 0xB0 through the validator deposit path creates a validator that currently cannot withdraw. Race condition: If an attacker deposits before the registration is processed, first-deposit-wins creates the validator and the registration is rejected. If both are in the same payload, the deposit is processed first and the result is the same. So a protocol must verify the registration's existence and expiry atomically with the deposit via EIP-4788, and if it verifies in a separate transaction it must wait until inclusion is finalized. State size: Active records total at most about 46 MB (88 bytes × 524,288), plus up to MAX_PREREGISTRATION_REQUESTS_PER_PAYLOAD × (SLOTS_PER_EPOCH - 1) = 124 expired records waiting for the next cleanup. At the target rate of 1 per block, filling 2**19 takes about 72.8 days, which exceeds the 36-day expiry window, so in steady state the active count stays near 2**18 (about 23 MB), and saturating it requires a rate above target, which makes the fee rise exponentially. If n requests per block (n > 1) are sustained, excess grows by n - 1 per block, and the fee exceeds 1 ETH after about 17 × ln(10^18) / (n - 1) blocks.

Current status and core developer call decisions

This EIP is in Draft status and was created on 2026-03-26. The system contract address, request type value, runtime bytecode, deployment transaction, test vectors and reference implementation are all TBD and are to be provided with the reference implementation. The consensus layer changes are also described as a "sketch" in the source, so the content may change. According to the core developer call records, it was discussed on 09.03 ACDC #186 but no decision was made. On 10.15 ACDC #189 the decision was deferred to the next call, and it was agreed to open the next call's scoping session with this item and revisit it in a 5-minute timebox. On 10.29 ACDC #190, EIP-8205 (deposit front-running fix) was declined for inclusion (DFI) in Hegota. The problem itself was acknowledged, and the group committed to resolving it in the post-quantum (PQ) deposit contract redesign.

Original EIP · ← EIPs