Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-7002: Execution layer triggerable withdrawals

Allow validators to trigger exits and partial withdrawals via their execution layer (0x01) withdrawal credentials

Authors Danny Ryan (@djrtwo), Mikhail Kalinin (@mkalinin), Ansgar Dietrichs (@adietrichs), Hsiao-Wei Wang (@hwwhww), lightclient (@lightclient)
Created 2023-05-09
Discussion Link https://ethereum-magicians.org/t/eip-7002-execution-layer-triggerable-exits/14195
Requires EIP-7685

Abstract

Adds a new mechanism to allow validators to trigger withdrawals and exits from their execution layer (0x01) withdrawal credentials.

These new execution layer exit messages are appended to the execution layer block and then processed by the consensus layer.

Motivation

Validators have two keys – an active key and a withdrawal credential. The active key takes the form of a BLS key, whereas the withdrawal credential can either be a BLS key (0x00) or an execution layer address (0x01). The active key is “hot”, actively signing and performing validator duties, whereas the withdrawal credential can remain “cold”, only performing limited operations in relation to withdrawing and ownership of the staked ETH. Due to this security relationship, the withdrawal credential ultimately is the key that owns the staked ETH and any rewards.

As currently specified, only the active key can initiate a validator exit. This means that in any non-standard custody relationships (i.e. active key is separate entity from withdrawal credentials), that the ultimate owner of the funds – the possessor of the withdrawal credentials – cannot independently choose to exit and begin the withdrawal process. This leads to either trust issues (e.g. ETH can be “held hostage” by the active key owner) or insufficient work-arounds such as pre-signed exits. Additionally, in the event that active keys are lost, a user should still be able to recover their funds by using their cold withdrawal credentials.

To ensure that the withdrawal credentials (owned by both EOAs and smart contracts) can trustlessly control the destiny of the staked ETH, this specification enables exits triggerable by 0x01 withdrawal credentials.

Note, 0x00 withdrawal credentials can be changed into 0x01 withdrawal credentials with a one-time signed message. Thus any functionality enabled for 0x01 credentials is defacto enabled for 0x00 credentials.

Specification

Constants

Name Value Comment
FORK_TIMESTAMP TBD Mainnet

Configuration

Name Value Comment
WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS 0xEd8EA01d70Cb49726175BCf2778B9C982912e017 Where to call and store relevant details about exit / partial withdrawal mechanism
WITHDRAWAL_REQUEST_TYPE 0x01 The EIP-7685 type prefix for withdrawal request
SYSTEM_ADDRESS 0xfffffffffffffffffffffffffffffffffffffffe Address used to invoke system operation on contract
EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT 0  
WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT 1  
WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT 2 Pointer to head of the withdrawal request message queue
WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT 3 Pointer to the tail of the withdrawal request message queue
WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET 4 The start memory slot of the in-state withdrawal request message queue
MAX_WITHDRAWAL_REQUESTS_PER_BLOCK 16 Maximum number of withdrawal requests that can be dequeued into a block
TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK 2  
MIN_WITHDRAWAL_REQUEST_FEE 1  
WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION 17  

Execution layer

Definitions

  • FORK_BLOCK – the first block in a blockchain with the timestamp greater or equal to FORK_TIMESTAMP.

Withdrawal request operation

The new withdrawal request operation is an EIP-7685 request with type 0x01 and consists of the following fields:

  1. source_address: Bytes20
  2. validator_pubkey: Bytes48
  3. amount: uint64

The EIP-7685 encoding of a withdrawal request MUST be computed as the follows:

encoded = WITHDRAWAL_REQUEST_TYPE ++ rlp([source_address, validator_pubkey, amount])

Withdrawal Request Contract

The contract has three different code paths, which can be summarized at a high level as follows:

  1. Add withdrawal request - requires a 56 byte input, the validator’s public key concatenated with uint64 amount value.
  2. Excess withdrawal requests getter - if the input length is zero, return the current excess withdrawal requests count.
  3. System process - if called by system address, pop off the withdrawal requests for the current block from the queue.
Add Withdrawal Request

If call data input to the contract is exactly 56 bytes, perform the following:

  • Ensure enough ETH was sent to cover the current withdrawal request fee (check_fee())
  • Increase withdrawal request count by 1 for the current block (increment_count())
  • Insert a withdrawal request into the queue for the source address and validator pubkey (insert_withdrawal_request_into_queue())

Specifically, the functionality is defined in pseudocode as the function add_withdrawal_request():

def add_withdrawal_request(Bytes48: validator_pubkey, uint64: amount):
    """
    Add withdrawal request adds new request to the withdrawal 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 withdrawal request count.
    count = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT)
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, count + 1)

    # Insert into queue.
    queue_tail_index = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT)
    queue_storage_slot = WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + queue_tail_index * 3
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot, msg.sender)
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1, validator_pubkey[0:32])
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2, validator_pubkey[32:48] ++ amount)
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, queue_tail_index + 1)
Fee calculation

The following pseudocode can compute the cost an individual withdrawal request, given a certain number of excess withdrawal requests.

def get_fee() -> int:
    excess = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT)
    return fake_exponential(
        MIN_WITHDRAWAL_REQUEST_FEE,
        excess,
        WITHDRAWAL_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
Excess Withdrawal Requests Getter

When the input to the contract is length zero, interpret this as a get request for the current excess withdrawal requests count.

def get_excess_withdrawal_requests():
    count = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT)
    return count
System Call

At the end of processing any execution block where block.timestamp >= FORK_TIMESTAMP (i.e. after processing all transactions and after performing the block body withdrawal requests validations), call the contract as SYSTEM_ADDRESS and perform the following:

  • The contract’s queue is updated based on withdrawal requests dequeued and the withdrawal requests queue head/tail are reset if the queue has been cleared (dequeue_withdrawal_requests())
  • The contract’s excess withdrawal requests are updated based on usage in the current block (update_excess_withdrawal_requests())
  • The contract’s withdrawal requests count is reset to 0 (reset_withdrawal_requests_count())

Specifically, the functionality is defined in pseudocode as the function read_withdrawal_requests():

###################
# Public function #
###################

def read_withdrawal_requests():
    reqs = dequeue_withdrawal_requests()
    update_excess_withdrawal_requests()
    reset_withdrawal_requests_count()
    return reqs

###########
# Helpers #
###########

class ValidatorWithdrawalRequest(object):
    source_address: Bytes20
    validator_pubkey: Bytes48
    amount: uint64

def dequeue_withdrawal_requests():
    queue_head_index = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT)
    queue_tail_index = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT)
    num_in_queue = queue_tail_index - queue_head_index
    num_dequeued = min(num_in_queue, MAX_WITHDRAWAL_REQUESTS_PER_BLOCK)

    reqs = []
    for i in range(num_dequeue):
        queue_storage_slot = WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + (queue_head_index + i) * 3
        source_address = address(sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot)[0:20])
        validator_pubkey = (
            sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1)[0:32] + sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[0:16]
        )
        amount = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[16:24]
        req = ValidatorWithdrawalRequest(
            source_address=Bytes20(source_address),
            validator_pubkey=Bytes48(validator_pubkey),
            amount=uint64(amount)
        )
        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(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, 0)
        sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, 0)
    else:
        sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, new_queue_head_index)

    return reqs

def update_excess_withdrawal_requests():
    previous_excess = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT)
    count = sload(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT)

    new_excess = 0
    if previous_excess + count > TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK:
        new_excess = previous_excess + exit - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK

    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, new_excess)

def reset_withdrawal_requests_count():
    sstore(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, 0)
Bytecode
caller
push20 0xfffffffffffffffffffffffffffffffffffffffe
eq
push1 0x90
jumpi

calldatasize
iszero
iszero
push1 0x28
jumpi

push0
sload
push0
mstore
push1 0x20
push0
return

jumpdest
calldatasize
push1 0x38
eq
iszero
push2 0x0121
jumpi

push1 0x11
push0
sload
push1 0x01
dup3
mul
push1 0x01
swap1
push0

jumpdest
push0
dup3
gt
iszero
push1 0x59
jumpi

dup2
add
swap1
dup4
mul
dup5
dup4
mul
swap1
div
swap2
push1 0x01
add
swap2
swap1
push1 0x3e
jump

jumpdest
swap1
swap4
swap1
div
callvalue
lt
push2 0x0121
jumpi

push1 0x01
sload
push1 0x01
add
push1 0x01
sstore
push1 0x03
sload
dup1
push1 0x03
mul
push1 0x04
add
caller
dup2
sstore
push1 0x01
add
push0
calldataload
dup2
sstore
push1 0x01
add
push1 0x20
calldataload
swap1
sstore
push1 0x01
add
push1 0x03
sstore
stop

jumpdest
push1 0x03
sload
push1 0x02
sload
dup1
dup3
sub
dup1
push1 0x10
gt
push1 0xa4
jumpi

pop
push1 0x10

jumpdest
push0

jumpdest
dup2
dup2
eq
push1 0xdd
jumpi

dup1
push1 0x4c
mul
dup4
dup3
add
push1 0x03
mul
push1 0x04
add
dup1
sload
swap1
push1 0x01
add
dup1
sload
swap1
push1 0x01
add
sload
swap2
push1 0x60
shl
dup4
mstore
dup3
push1 0x14
add
mstore
swap1
push1 0x34
add
mstore
push1 0x01
add
push1 0xa6
jump

jumpdest
swap2
add
dup1
swap3
eq
push1 0xed
jumpi

swap1
push1 0x02
sstore
push1 0xf8
jump

jumpdest
swap1
pop
push0
push1 0x02
sstore
push0
push1 0x03
sstore

jumpdest
push0
sload
push1 0x01
sload
push1 0x02
dup3
dup3
add
gt
push2 0x010f
jumpi

pop
pop
push0
push2 0x0115
jump

jumpdest
add
push1 0x02
swap1
sub

jumpdest
push0
sstore
push0
push1 0x01
sstore
push1 0x4c
mul
push0
return

jumpdest
push0
push0
revert
Deployment

The withdrawal requests contract is deployed like any other smart contract. A special synthetic address is generated by working backwards from the desired deployment transaction:

{
  "type": "0x0",
  "nonce": "0x0",
  "to": null,
  "gas": "0x3d090",
  "gasPrice": "0xe8d4a51000",
  "maxPriorityFeePerGas": null,
  "maxFeePerGas": null,
  "value": "0x0",
  "input": "0x61012580600a5f395ff33373fffffffffffffffffffffffffffffffffffffffe146090573615156028575f545f5260205ff35b36603814156101215760115f54600182026001905f5b5f82111560595781019083028483029004916001019190603e565b90939004341061012157600154600101600155600354806003026004013381556001015f3581556001016020359055600101600355005b6003546002548082038060101160a4575060105b5f5b81811460dd5780604c02838201600302600401805490600101805490600101549160601b83528260140152906034015260010160a6565b910180921460ed579060025560f8565b90505f6002555f6003555b5f5460015460028282011161010f5750505f610115565b01600290035b5f555f600155604c025ff35b5f5ffd",
  "v": "0x1b",
  "r": "0x539",
  "s": "0x1337009315daa4627a267f",
  "hash": "0xea81ea60a9d96013e2cfcc6070f0d5b983f3875454e4449c3063e38b130185ad"
}
Sender: 0x3da359dC1d98B53f32848dFfDC421410a47bbBD5
Address: 0xEd8EA01d70Cb49726175BCf2778B9C982912e017

Consensus layer

Full specification

Sketch of spec:

  • New operation ExecutionLayerWithdrawalRequest
  • Will show up in ExecutionPayload as an SSZ List bound by length MAX_WITHDRAWAL_REQUESTS_PER_BLOCK
  • New function that has similar functionality to process_voluntary_exit but can fail validations (e.g. validator is already exited) without the block failing (similar to deposit coming from EL)
  • This function is called in process_operations for each ExecutionLayerWithdrawalRequest found in the ExecutionPayload

Rationale

validator_pubkey field

Multiple validators can utilize the same execution layer withdrawal credential, thus the validator_pubkey field is utilized to disambiguate which validator is being exited.

Note, validator_index also disambiguates validators.

Message queue

The contract maintains and in-state queue of withdrawal request messages to be dequeued each block into the block and thus into the execution layer.

The number of withdrawal requests that can be passed into the consensus layer are bound by MAX_WITHDRAWAL_REQUESTS_PER_BLOCK to bound the load both on the block size as well as on the consensus layer processing. 16 has been chosen for MAX_WITHDRAWAL_REQUESTS_PER_BLOCK to be in line with the bounds of similar operations on the beacon chain – e.g. VoluntaryExit and Deposit.

Although there is a maximum number of withdrawal requests that can passed to the consensus layer each block, the execution layer gas limit can provide for far more calls to the withdrawal request predeploy contract at each block. The queue then allows for these calls to successfully be made while still maintaining a system rate limit.

The alternative design considered was to have calls to the contract fail after MAX_WITHDRAWAL_REQUESTS_PER_BLOCK successful calls were made within the context of a single block. This would eliminate the need for the message queue, but would come at the cost of a bad UX of contract call failures in times of high exiting. The complexity to mitigate this bad UX is relatively low and is currently favored.

Utilizing CALL to return excess payment

Calls to the contract require a fee payment defined by the current state of the contract. Smart contracts can easily perform a read/calculation to pay the precise fee, whereas EOAs will likely need to compute and send some amount over the current fee at time of signing the transaction. This will result in EOAs having fee payment overages in the normal case. These should be returned to the caller.

There are two potential designs to return excess fee payments to the caller (1) use an EVM CALL with some gas stipend or (2) have special functionality to allow the contract to “credit” the caller’s account with the excess fee.

Option (1) has been selected in the current specification because it utilizes less exceptional functionality and is likely simpler to implement and ensure correctness. The current version sends a gas stipen of 2300. This is following the (outdated) solidity pattern primarily to simplify contract gas accounting (allowing it to be a fixed instead of dynamic cost). The CALL could forward the maximum allowed gas but would then require the cost of the contract to be dynamic.

Option (2) utilizes custom logic (exceptional to base EVM logic) to credit the excess back to the callers balance. This would potentially simplify concerns around contract gas costs/metering, but at the cost of non-standard EVM complexity. We are open to this path, but want to solicit more input before writing it into the speficiation.

Rate limiting using a fee

Transactions are naturally rate-limited in the execution layer via the gas limit, but an adversary willing to pay market-rate gas fees (and potentially utilize builder markets to pay for front-of-block transaction inclusion) can fill up the exit operation limits for relatively cheap, thus griefing honest validators that want to make a withdrawal request.

There are two general approaches to combat this griefing – (a) only allow validators to send such messages and with a limit per time period or (b) utilize an economic method to make such griefing increasingly costly.

Method (a) (not used in this EIP) would require EIP-4788 (the BEACON_ROOT opcode) against which to prove withdrawal credentials in relation to validator pubkeys as well as a data-structure to track requests per-unit-time (e.g. 4 months) to ensure that a validator cannot grief the mechanism by submitting many requests. The downsides of this method are that it requires another cross-layer EIP and that it is of higher cross-layer complexity (e.g. care that might need to be taken in future upgrades if, for example, the shape of the merkle tree of BEACON_ROOT changes, then the contract and proof structure might need to be updated).

Method (b) has been utilized in this EIP to eliminate additional EIP requirements and to reduce cross-layer complexity to allow for correctness of this EIP (now and in the future) to be easier to analyze. The EIP-1559-style mechanism with a dynamically adjusting fee mechanism allows for users to pay MIN_WITHDRAWAL_REQUEST_FEE for withdrawal requests in the normal case (fewer than 2 per block on average), but scales the fee up exponentially in response to high usage (i.e. potential abuse).

TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK configuration value

TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK has been selected as 2 such that even if all ETH is staked (~120M ETH -> 3.75M validators), the 64 validator per epoch target (2 * 32 slots) still exceeds the per-epoch exit churn limit on the consensus layer (defined by get_validator_churn_limit()) at such values – 57 validators per epoch (3.75M // 65536).

Fee update rule

The fee update rule is intended to approximate the formula fee = MIN_WITHDRAWAL_REQUEST_FEE * e**(excess / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION), where excess is the total “extra” amount of withdrawal requests that the chain has processed relative to the “targeted” number (TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK per block).

Like EIP-1559, it’s a self-correcting formula: as the excess goes higher, the fee increases exponentially, reducing usage and eventually forcing the excess back down.

The block-by-block behavior is roughly as follows. If block N processes X requests, then at the end of block N excess increases by X - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK, and so the fee in block N+1 increases by a factor of e**((X - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK) / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION). Hence, it has a similar effect to the existing EIP-1559, but is more “stable” in the sense that it responds in the same way to the same total withdrawal requests regardless of how they are distributed over time.

The parameter WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION controls the maximum downwards rate of change of the blob gas price. It is chosen to target a maximum downwards change rate of e(TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION) ≈ 1.125 per block. The maximum upwards change per block is e((MAX_WITHDRAWAL_REQUESTS_PER_BLOCK - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK) / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION) ≈ 2.279.

Withdrawal requests inside of the block

Withdrawal requests are placed into the actual body of the block (and execution payload in the consensus layer).

There is a strong design requirement that the consensus layer and execution layer can execute independently of each other. This means, in this case, that the consensus layer cannot rely upon a synchronous call to the execution layer to get the required withdrawal requests for the current block. Instead, the requests must be embedded in the shared data-structure of the execution payload such that if the execution layer is offline, the consensus layer still has the requisite data to fully execute the consensus portion of the state transition function.

Backwards Compatibility

This EIP introduces backwards incompatible changes to the block structure and block validation rule set. But neither of these changes break anything related to current user activity and experience.

Security Considerations

Impact on existing custody relationships

There might be existing custody relationships and/or products that rely upon the assumption that the withdrawal credentials cannot trigger a withdrawal request. We are currently confident that the additional withdrawal credentials feature does not impact the security of existing validators because:

  1. The withdrawal credentials ultimately own the funds so allowing them to exit staking is natural with respect to ownership.
  2. We are currently not aware of any such custody relationships and/or products that do rely on the lack of this feature.

In the event that existing validators/custodians rely on this, then the validators can be exited and restaked utilizing 0x01 withdrawal credentials pointing to a smart contract that simulates this behaviour.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Danny Ryan (@djrtwo), Mikhail Kalinin (@mkalinin), Ansgar Dietrichs (@adietrichs), Hsiao-Wei Wang (@hwwhww), lightclient (@lightclient), "EIP-7002: Execution layer triggerable withdrawals [DRAFT]," Ethereum Improvement Proposals, no. 7002, May 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7002.