HomeEIPsNewsletter
EIPsEIP-7251
EIP-7251

Increase the MAX_EFFECTIVE_BALANCE

Allow validators to have larger effective balances, while maintaining the 32 ETH lower bound.
ReviewStandards Track: Core
Created: 2023-06-28
Requires: EIP-7002, EIP-7685
mike (@michaelneuder), Francesco (@fradamt), dapplion (@dapplion), Mikhail (@mkalinin), Aditya (@adiasg), Justin (@justindrake), lightclient (@lightclient)
DiscussionsOriginal linkEdit
1 min read
Anyone may contribute to propose contents.
Go propose
Videos

PEEPanEIP#127:EIP-7251:Increase MAX_EFFECTIVE_BALANCE

Original

Abstract

Increases the constant MAX_EFFECTIVE_BALANCE, while keeping the minimum staking balance 32 ETH. This permits large node operators to consolidate into fewer validators while also allowing solo-stakers to earn compounding rewards and stake in more flexible increments.

Motivation

As of October 3, 2023, there are currently over 830,000 validators participating in the consensus layer. The size of this set continues to grow due, in part, to the MAX_EFFECTIVE_BALANCE, which limits the stake of a single validator to 32 ETH. This leads to large amounts of "redundant validators", which are controlled by a single entity, possibly running on the same beacon node, but with distinct BLS signing keys. The limit on the MAX_EFFECTIVE_BALANCE is technical debt from the original sharding design, in which subcommittees (not the attesting committee but the committee calculated in is_aggregator) needed to be majority honest. As a result, keeping the weights of subcommittee members approximately equal reduced the risk of a single large validator containing too much influence. Under the current design, these subcommittees are only used for attestation aggregation, and thus only have a 1/N honesty assumption.

With the security model of the protocol no longer dependent on a low value for MAX_EFFECTIVE_BALANCE, we propose raising this value while keeping the minimum validator threshold of 32 ETH. This increase aims to reduce the validator set size, thereby reducing the number of P2P messages over the network, the number of BLS signatures that need to be aggregated each epoch, and the BeaconState memory footprint. This change adds value for both small and large validators. Large validators can consolidate to run fewer validators and thus fewer beacon nodes. Small validators now benefit from compounding rewards and the ability to stake in more flexible increments (e.g., the ability to stake 40 ETH instead of needing to accumulate 64 ETH to run two validators today).

Specification

Constants

Execution layer

NameValueComment
CONSOLIDATION_REQUEST_TYPE0x02The EIP-7685 type prefix for consolidation request
CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS0x01aBEa29659e5e97C95107F20bb753cD3e09bBBbWhere to call and store relevant details about consolidation request mechanism
SYSTEM_ADDRESS0xfffffffffffffffffffffffffffffffffffffffeAddress used to invoke system operation on contract
EXCESS_CONSOLIDATION_REQUESTS_STORAGE_SLOT0
CONSOLIDATION_REQUEST_COUNT_STORAGE_SLOT1
CONSOLIDATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT2Pointer to the head of the consolidation request message queue
CONSOLIDATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT3Pointer to the tail of the consolidation request message queue
CONSOLIDATION_REQUEST_QUEUE_STORAGE_OFFSET4The start memory slot of the in-state consolidation request message queue
MAX_CONSOLIDATION_REQUESTS_PER_BLOCK1Maximum number of consolidation requests that can be dequeued into a block
TARGET_CONSOLIDATION_REQUESTS_PER_BLOCK1
MIN_CONSOLIDATION_REQUEST_FEE1
CONSOLIDATION_REQUEST_FEE_UPDATE_FRACTION17
EXCESS_INHIBITOR2**256-1Excess value used to compute the fee before the first system call
FORK_TIMESTAMPTBDMainnet

Consensus layer

NameValue
COMPOUNDING_WITHDRAWAL_PREFIXBytes1('0x02')
MIN_ACTIVATION_BALANCEGwei(2**5 * 10**9) (32 ETH)
MAX_EFFECTIVE_BALANCEGwei(2**11 * 10**9) (2048 ETH)

Execution layer

Consolidation request

The new consolidation request is an EIP-7685 request with type 0x02 consisting of the following fields:

  1. source_address: Bytes20
  2. source_pubkey: Bytes48
  3. target_pubkey: Bytes48

The EIP-7685 encoding of a consolidation request is as follows. Note we simply return the encoded request value as returned by the contract.

request_type = CONSOLIDATION_REQUEST_TYPE request_data = dequeue_consolidation_requests()

Consolidation request contract

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

  1. Add consolidation request - requires a 96 byte input, concatenated public keys of the source and the target validators.
  2. Excess consolidation requests getter - if the input length is zero, return the current excess consolidation requests count.
  3. System process - if called by system address, pop off the consolidation requests for the current block from the queue.
Add Consolidation Request

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

  1. Ensure enough ETH was sent to cover the current consolidation request fee (check_fee())
  2. Increase consolidation request count by 1 for the current block (increment_count())
  3. Insert a consolidation request into the queue for the source address and pubkeys of the source and the target (insert_withdrawal_request_into_queue())

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

def add_consolidation_request(Bytes48: source_pubkey, Bytes48: target_pubkey): """ Add consolidation request adds new request to the consolidation 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 consolidation request count. count = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_COUNT_STORAGE_SLOT) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_COUNT_STORAGE_SLOT, count + 1) # Insert into queue. queue_tail_index = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT) queue_storage_slot = CONSOLIDATION_REQUEST_QUEUE_STORAGE_OFFSET + queue_tail_index * 4 sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot, msg.sender) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1, source_pubkey[0:32]) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2, source_pubkey[32:48] ++ target_pubkey[0:16]) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 3, target_pubkey[16:48]) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT, queue_tail_index + 1)
Fee calculation

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

def get_fee() -> int: excess = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_CONSOLIDATION_REQUESTS_STORAGE_SLOT) require(excess != EXCESS_INHIBITOR, 'Inhibitor still active') return fake_exponential( MIN_CONSOLIDATION_REQUEST_FEE, excess, CONSOLIDATION_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
Fee Getter

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().

System Call

If the contract is called as SYSTEM_ADDRESS with an empty input data, perform the following:

  • The contract's queue is updated based on consolidation requests dequeued and the consolidation requests queue head/tail are reset if the queue has been cleared (dequeue_consolidation_requests())
  • The contract's excess consolidation requests are updated based on usage in the current block (update_excess_consolidation_requests())
  • The contract's consolidation requests count is reset to 0 (reset_consolidation_requests_count())

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

################### # Public function # ################### def process_consolidation_requests(): reqs = dequeue_consolidation_requests() update_excess_consolidation_requests() reset_consolidation_requests_count() return ssz.serialize(reqs) ########### # Helpers # ########### class ConsolidationRequest(object): source_address: Bytes20 source_pubkey: Bytes48 target_pubkey: Bytes48 def dequeue_consolidation_requests(): queue_head_index = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT) queue_tail_index = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT) num_in_queue = queue_tail_index - queue_head_index num_dequeued = min(num_in_queue, MAX_CONSOLIDATION_REQUESTS_PER_BLOCK) reqs = [] for i in range(num_dequeued): queue_storage_slot = CONSOLIDATION_REQUEST_QUEUE_STORAGE_OFFSET + (queue_head_index + i) * 4 source_address = address(sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot)[0:20]) source_pubkey = ( sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 1)[0:32] + sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[0:16] ) target_pubkey = ( sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 2)[16:32] + sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, queue_storage_slot + 3)[0:32] ) req = ConsolidationRequest( source_address=Bytes20(source_address), source_pubkey=Bytes48(source_pubkey), target_pubkey=Bytes48(target_pubkey) ) 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(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT, 0) sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_TAIL_STORAGE_SLOT, 0) else: sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_QUEUE_HEAD_STORAGE_SLOT, new_queue_head_index) return reqs def update_excess_consolidation_requests(): previous_excess = sload(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_CONSOLIDATION_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(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_COUNT_STORAGE_SLOT) new_excess = 0 if previous_excess + count > TARGET_CONSOLIDATION_REQUESTS_PER_BLOCK: new_excess = previous_excess + count - TARGET_CONSOLIDATION_REQUESTS_PER_BLOCK sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, EXCESS_CONSOLIDATION_REQUESTS_STORAGE_SLOT, new_excess) def reset_consolidation_requests_count(): sstore(CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_COUNT_STORAGE_SLOT, 0)
Bytecode
caller push20 0xfffffffffffffffffffffffffffffffffffffffe eq push1 0xd3 jumpi push1 0x11 push0 sload dup1 push32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff eq push2 0x019a jumpi push1 0x01 dup3 mul push1 0x01 swap1 push0 jumpdest push0 dup3 gt iszero push1 0x68 jumpi dup2 add swap1 dup4 mul dup5 dup4 mul swap1 div swap2 push1 0x01 add swap2 swap1 push1 0x4d jump jumpdest swap1 swap4 swap1 div swap3 pop pop pop calldatasize push1 0x60 eq push1 0x88 jumpi calldatasize push2 0x019a jumpi callvalue push2 0x019a jumpi push0 mstore push1 0x20 push0 return jumpdest callvalue lt push2 0x019a jumpi push1 0x01 sload push1 0x01 add push1 0x01 sstore push1 0x03 sload dup1 push1 0x04 mul push1 0x04 add caller dup2 sstore push1 0x01 add push0 calldataload dup2 sstore push1 0x01 add push1 0x20 calldataload dup2 sstore push1 0x01 add push1 0x40 calldataload swap1 sstore caller push1 0x60 shl push0 mstore push1 0x60 push0 push1 0x14 calldatacopy push1 0x74 push0 log0 push1 0x01 add push1 0x03 sstore stop jumpdest push1 0x03 sload push1 0x02 sload dup1 dup3 sub dup1 push1 0x01 gt push1 0xe7 jumpi pop push1 0x01 jumpdest push0 jumpdest dup2 dup2 eq push2 0x0129 jumpi dup3 dup2 add push1 0x04 mul push1 0x04 add dup2 push1 0x74 mul dup2 sload push1 0x60 shl dup2 mstore push1 0x14 add dup2 push1 0x01 add sload dup2 mstore push1 0x20 add dup2 push1 0x02 add sload dup2 mstore push1 0x20 add swap1 push1 0x03 add sload swap1 mstore push1 0x01 add push1 0xe9 jump jumpdest swap2 add dup1 swap3 eq push2 0x013b jumpi swap1 push1 0x02 sstore push2 0x0146 jump jumpdest swap1 pop push0 push1 0x02 sstore push0 push1 0x03 sstore jumpdest push0 sload dup1 push32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff eq iszero push2 0x0173 jumpi pop push0 jumpdest push1 0x01 sload push1 0x01 dup3 dup3 add gt push2 0x0188 jumpi pop pop push0 push2 0x018e jump jumpdest add push1 0x01 swap1 sub jumpdest push0 sstore push0 push1 0x01 sstore push1 0x74 mul push0 return jumpdest push0 push0 revert
Deployment

The consolidation 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": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff5f5561019e80602d5f395ff33373fffffffffffffffffffffffffffffffffffffffe1460d35760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461019a57600182026001905f5b5f82111560685781019083028483029004916001019190604d565b9093900492505050366060146088573661019a573461019a575f5260205ff35b341061019a57600154600101600155600354806004026004013381556001015f358155600101602035815560010160403590553360601b5f5260605f60143760745fa0600101600355005b6003546002548082038060011160e7575060015b5f5b8181146101295782810160040260040181607402815460601b815260140181600101548152602001816002015481526020019060030154905260010160e9565b910180921461013b5790600255610146565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561017357505f5b6001546001828201116101885750505f61018e565b01600190035b5f555f6001556074025ff35b5f5ffd", "v": "0x1b", "r": "0x539", "s": "0x4b026bde3de3d21a", "hash": "0x5c86483822c8978690fd23fec89ba75e205c734020315827c50e2def49f924d4" }
Sender: 0xB64F31e716F43404Fc452Ad957e37B45B2A9AC86
Address: 0x0046BB33B9eA028AE30BAd20702e36Ea8099BBbb

Block processing

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 requests validations) client software MUST take the following steps:

  1. Call the contract as SYSTEM_ADDRESS and empty input data to trigger the system subroutine execute.
  2. Check that consolidation requests in the EIP-7685 requests list matches the list returned from dequeue_consolidation_requests() function of the smart contract respecting the order of the returned requests. If this condition does not hold, the block MUST be deemed invalid.

Consensus layer

The defining features of this EIP are:

  1. Increasing the MAX_EFFECTIVE_BALANCE, while creating a MIN_ACTIVATION_BALANCE. The core feature of allowing variable size validators.
  2. Allowing for multiple validator indices to be combined through the protocol. A mechanism by which large node operators can combine validators without cycling through the exit and activation queues.
  3. Adding execution layer partial withdrawals (part of EIP-7002). Allowing Execution Layer messages to trigger partial withdrawals in addition to full exits (e.g., a 100 ETH validator can remove up to 68 ETH without exiting the validator).
  4. Removing the initial slashing penalty (still in discussion). This reduces the risk of consolidation for large validators.

The Rationale section contains an explanation for each of these proposed core features. A sketch of the resulting changes to the consensus layer is included below.

  1. Add COMPOUNDING_WITHDRAWAL_PREFIX and MIN_ACTIVATION_BALANCE constants, while updating the value of MAX_EFFECTIVE_BALANCE.
  2. Create the PendingDeposit container, which is used to track incoming deposits in the weight-based rate limiting mechanism.
  3. Update the BeaconState with fields needed for deposit and exit queue weight-based rate limiting.
  4. Modify is_eligible_for_activation_queue to check against MIN_ACTIVATION_BALANCE rather than MAX_EFFECTIVE_BALANCE.
  5. Modify get_validator_churn_limit to depend on the validator weight rather than the validator count.
  6. Create a helper compute_exit_epoch_and_update_churn to calculate the exit epoch based on the current pending withdrawals.
  7. Modify initiate_validator_exit to rate limit the exit queue by balance rather than the number of validators.
  8. Modify initialize_beacon_state_from_eth1 to use MIN_ACTIVATION_BALANCE.
  9. Modify process_registry_updates to activate all eligible validators.
  10. Add a per-epoch helper, process_pending_balance_deposits, to consume some of the pending deposits.
  11. Modify get_validator_from_deposit to initialize the effective balance to zero (it's updated by the pending deposit flow).
  12. Modify apply_deposit to store incoming deposits in state.pending_balance_deposits.
  13. Modify is_aggregator to be weight-based.
  14. Modify compute_weak_subjectivity_period to use the new churn limit function.
  15. Add has_compounding_withdrawal_credential to check for the 0x02 credential.
  16. Modify is_fully_withdrawable_validator to check for compounding credentials.
  17. Add get_validator_excess_balance to calculate the excess balance of validators.
  18. Modify is_partially_withdrawable_validator to check for excess balance.
  19. Modify get_expected_withdrawals to use excess balance.

Rationale

This EIP aims to reduce the total number of validators without changing anything about the economic security of the protocol. It provides a mechanism by which large node operators who control significant amounts of stake can consolidate into fewer validators. We analyze the reasoning behind each of the core features.

  1. Increasing the MAX_EFFECTIVE_BALANCE, while creating a MIN_ACTIVATION_BALANCE.
    • While increasing the MAX_EFFECTIVE_BALANCE to allow larger-stake validators, it is important to keep the lower bound of 32 ETH (by introducing a new constant – MIN_ACTIVATION_BALANCE) to encourage solo-staking.
  2. Allowing for multiple validator indices to be combined through the protocol.
    • For large staking pools that already control thousands of validators, exiting and re-entering would be extremely slow and costly. The adoption of the EIP will be much higher by allowing in-protocol consolidation.
  3. Adding execution layer partial withdrawals (part of EIP-7002).
    • For validators that choose to raise their effective balance ceiling, allowing for custom partial withdrawals triggered from the execution layer increases the flexibility of the staking configurations. Validators can choose when and how much they withdraw but will have to pay gas for the EL transaction.
  4. Removing the initial slashing penalty (still in discussion).
    • To encourage consolidation, we could modify the slashing penalties. The biggest hit comes from the initial penalty of 1/32 of the validator's effective balance. Since this scales linearly on the effective balance, the higher-stake validators directly incur higher risk. By changing the scaling properties, we could make consolidation more attractive.

Backwards Compatibility

This EIP introduces backward incompatible changes to the block validation rule set on the consensus layer and must be accompanied by a hard fork. These changes do not break anything related to current user activity and experience.

Security Considerations

This change modifies committees and churn, but doesn't significantly impact the security properties.

Security of attestation committees

Given full consolidation as the worst case, the probability of an adversarial takeover of a committee remains low. Even in a high consolidation scenario, the required share of honest validators remains well below the 2/3 supermajority needed for finality.

Aggregator selection

In the original sharding roadmap, subcommittees were required to be secure with extremely high probability. Now with the sole responsibility of attestation aggregation, we only require each committee to have at least one honest aggregator. Currently, aggregators are selected through a VRF lottery, targeting several validator units that can be biased by non-consolidated attackers. This proposal changes the VRF lottery to consider weight, so the probability of having at least one honest aggregator is not worse.

Proposer selection probability

Proposer selection is already weighted by the ratio of their effective balance to MAX_EFFECTIVE_BALANCE. Due to the lower probabilities, this change will slightly increase the time it takes to calculate the next proposer index.

Sync committee selection probability

Sync committee selection is also already weighted by effective balance, so this proposal does not require modifications to the sync protocol. Light clients can still check that a super-majority of participants have signed an update irrespective of their weights since we maintain a weight-based selection probability.

Churn invariants

This proposal maintains the activation and exit churn invariants limiting active weight instead of validator count. Balance top-ups are now handled explicitly, being subject to the same activation queue as full deposits.

Fee Overpayment

Calls to the system contract require a fee payment defined by the current contract state. Overpaid fees are not returned to the caller. It is not generally possible to compute the exact required fee amount ahead of time. When adding a consolidation request from a contract, the contract can perform a read operation to check for the current fee and then pay exactly the required amount. Here is an example in Solidity:

function addConsolidation(bytes memory srcPubkey, bytes memory targetPubkey) private {
    assert(srcPubkey.length == 48);
    assert(targetPubkey.length == 48);

    // Read current fee from the contract.
    (bool readOK, bytes memory feeData) = ConsolidationsContract.staticcall('');
    if (!readOK) {
        revert('reading fee failed');
    }
    uint256 fee = uint256(bytes32(feeData));

    // Add the request.
    bytes memory callData = bytes.concat(srcPubkey, targetPubkey);
    (bool writeOK,) = ConsolidationsContract.call{value: fee}(callData);
    if (!writeOK) {
        revert('adding request failed');
    }
}

Note: the system contract uses the EVM CALLER operation (Solidity: msg.sender) to get the address used in the consolidation request, i.e. the address that calls the system contract must match the 0x01 withdrawal credential recorded in the beacon state.

Using an EOA to request consolidations will always result in overpayment of fees. There is no way for an EOA to use a wrapper contract to request a consolidation. And even if a way existed, the gas cost of returning the overage would likely be higher than the overage itself. If requesting consolidations from an EOA to the system contract is desired, we recommend that users perform transaction simulations to estimate a reasonable fee amount to send.

Copyright and related rights waived via CC0.

Further reading
EIP-7251 MaxEB Breakout Call #3
Learn more
EIP-7251 MaxEB Breakout Call #4
Learn more
Adopted by projects
Anyone may contribute to propose contents.
Go propose

Not miss a beat of EIPs' update?

Subscribe EIPs Fun to receive the latest updates of EIPs Good for Buidlers to follow up.

View all
Serve Ethereum Builders, Scale the Community.
Resources
GitHub
Supported by