Try   HackMD

Eth2 to Eth1 Validator's withdrawal design [WIP]

Currently Ethereum 2 supports deposits with the only option, deposits from Ethereum 1 and there are no options for validator's stack or its part to be withdrawn. We want to add an option for Ethereum 1 withdrawals following validator exit as the simplest approach for validator owners to get their deposit back with rewards. No option for partial withdrawal is suggested due to higher complexity and lower safety of possible solutions and minor losses on reentry which should take about a day. Our highest priority is to make deposit-withdrawal processes handled with maximum level of autonomy and separation of validator and funder's roles to make possible creation of shared owned validators. Concept of Ethereum shared stacking pool is created according to the specification and currently in testing.

Withdrawal implementation requires a set of changes in both ETH2 BeaconChain and ETH1.

Contents:
1. Changes in BeaconChain
  1.1 Withdrawal credentials for Eth1 withdrawal
    1.1.1 Message for changing withdrawal credentials
  1.2 Withdrawals
  1.3 Withdrawal processing
  1.4 JSON-RPC update
1.5 Partial withdrawals
1.6 Queues
2. Changes in ETH1
  2.1 Changes to opcodes
  2.2 System contract in Eth1
3. Discussion

Warning: This document is under development

Changes in BeaconChain ^

Withdrawal credentials for Eth1 withdrawal ^

Proposed by @djrtwo in eth2.0-specs#2149, approved and merged. Adds 0x01, ETH1_ADDRESS_WITHDRAWAL_PREFIX prefix for withdrawal_credentials field of Deposit and thereafter Validator, setting withdrawal target to specific ETH1 address without any possibility to change withdrawal credentials in future to different target including other ETH1 addresses as no specific public key is left for withdrawal for such deposits.

Message for changing withdrawal credentials ^

Proposed in eth2.0-specs#2213, discussion. Message BLSSetWithdrawal could be issued and processed one time for each validator. It can't change one BLS withdrawal credentials to another. It can only change BLS creds to non-BLS creds. So, it will include change to the already approved ETH1 address withdrawal target right from the box.

Withdrawals ^

We define Withdrawal object:

class Withdrawal(Container): validator_index: ValidatorIndex withdrawal_credentials: Bytes32 withdrawn_epoch: Epoch amount: Gwei

We expect that Validator's pubkey couldn't be reused after Validator's exit and information about slashing and withdrawal epoch could be obtained from appropriate Validator object. Next, add Withdrawal objects list or similar structure to BeaconState.

class BeaconState(Container): # [...] # Registry validators: List[Validator, VALIDATOR_REGISTRY_LIMIT] balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT] # Withdrawals withdrawals: List[Withdrawal, WITHDRAWAL_REGISTRY_LIMIT] # Randomness # [...]

and define list limits:

Name Value Unit Duration
WITHDRAWAL_REGISTRY_LIMIT uint64(2**40) (= 1,099,511,627,776) withdrawals

Withdrawal processing ^

We have two options for withdrawal initiation:

  • Withdrawal message. In this case it should be signed by Validator as withdrawals with ETH1 withdrawal_credentials already don't have a dedicated withdrawal key. On one side it simplifies withdrawal processing doing it only on request, but it's not clear whether it's Validator's responsibility, as validator key owner could block withdrawal to any period of time with such ability.
  • Automatical processing. We could check validator after exit if it's eligible for withdrawal processing and perform it. If not, keep it unchanged until withdrawal is possible. Looks better but there is a room for mistakes.

Therefore we choose automatic processing but it should conform to the following properties:

  • no Validator could be processed to withdrawal 2 times successfully, no double withdrawal
  • if not processed successfully, Validator record should remain unchanged to be processed in future

In the near term second case means that Validator with withdrawal_credentials starting with BLS_WITHDRAWAL_PREFIX after exit remains unchanged without successful withdrawal, but if withdrawal_credentials are changed to one with ETH1_ADDRESS_WITHDRAWAL_PREFIX using appropriate message it should be reprocessed after that, as withdrawal becomes possible.

We've started this approach with boolean flag, but later tend to particular epoch number to omit field duplication in Validator and Withdrawal. We update Validator to change withdrawn epoch from FAR_FUTURE_EPOCH after successful Withdrawal creation:

class Validator(Container): pubkey: BLSPubkey withdrawal_credentials: Bytes32 # Commitment to pubkey for withdrawals effective_balance: Gwei # Balance at stake slashed: boolean # Status epochs activation_eligibility_epoch: Epoch # When criteria for activation were met activation_epoch: Epoch exit_epoch: Epoch withdrawable_epoch: Epoch # When validator can withdraw funds withdrawn_epoch: Epoch # When balance was withdrawn

We set withdrawn_epoch to 0 by default in order to compliment partial rewards withdrawals in future. There are several cases where even withdrawn validators could be updated, for example, in deposit processing, which should be avoided. All specifications should be checked and protected from this possibility.

Generally, we define withdrawal processing in the following manner:

def process_withdrawal(prefix: Byte1): if prefix == ETH1_ADDRESS_WITHDRAWAL_PREFIX: return process_eth1_withdrawal else: return lambda state, index: None def process_withdrawals(state: BeaconState) -> None: epoch = get_current_epoch(state) eligible_validators = [ (i, v) for i, v in enumerate(state.validators) if v.withdrawable_epoch <= epoch and v.withdrawn_epoch < v.withdrawable_epoch] for (i, v) in eligible_validators: process_withdrawal(Byte1(v.withdrawal_credentials[:1]))(state, i) def process_eth1_withdrawal(state: BeaconState, index: ValidatorIndex) -> None validator = state.validators[index] epoch = get_current_epoch(state) assert validator.withdrawable_epoch <= epoch assert ETH1_ADDRESS_WITHDRAWAL_PREFIX == Byte1(validator.withdrawal_credentials[:1]) withdrawal = Withdrawal( validator_index: index, withdrawal_credentials: validator.withdrawal_credentials, withdrawn_epoch: epoch, amount: state.balances[index] ) state.withdrawals.append(withdrawal) state.validators[index].effective_balance = 0 state.validators[index].withdrawn_epoch = epoch state.balances[index] = 0

JSON-RPC update ^

In order to get proofs for Withdrawals, we should add an appropriate JSON-RPC method, so clients will be able to get all required data. Let's call it

'/eth/v1/beacon/states/{state_id}/withdrawal': get: operationId: getWithdrawal summary: Get withdrawal description: Retrieves withdrawal with provided pubkey hash. tags: - Beacon parameters: - name: state_id in: path required: true example: head schema: type: string description: | State identifier. Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \<slot\>, \<hex encoded stateRoot with 0x prefix\>. - name: validator_index in: query required: true description: Corresponding validator's index. schema: type: string example: '1' responses: '200': description: Success content: application/json: schema: title: WithdrawalWithProofResponse type: object properties: data: type: object description: 'Withdrawal object with proofs up to BeaconBlock root' properties: beacon_block_root: allOf: - type: string example: '0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2' pattern: '^0x[a-fA-F0-9]{64}$' - description: Root of BeaconBlock, proofs are constructed at. slot: allOf: - type: string example: '1' - description: The slot to which beacon_block_root corresponds. proof: description: 'Proof of Withdrawal object as described in [`Merkle multiproofs`](https://github.com/ethereum/eth2.0-specs/blob/dev/ssz/merkle-proofs.md#merkle-multiproofs)' type: array items: allOf: - type: string example: '0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2' pattern: '^0x[a-fA-F0-9]{64}$' - description: Bytes32 index: allOf: - type: string example: '1' - description: '[`Generalized index`](https://github.com/ethereum/eth2.0-specs/blob/dev/ssz/merkle-proofs.md#generalized-merkle-tree-index) of Withdrawal object' withdrawal: type: object description: 'Withdrawal object' properties: validator_index: allOf: - type: string example: '1' - description: validator index withdrawal_credentials: allOf: - type: string example: '0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2' pattern: '^0x[a-fA-F0-9]{64}$' - description: Withdrawal credentials of Validator withdrawn_epoch: allOf: - type: string example: '1' - description: epoch of withdrawal amount: allOf: - type: string example: '1' - description: amount of withdrawal in GWei '400': description: The state ID supplied could not be parsed content: application/json: schema: allOf: - type: object properties: code: description: Either specific error code in case of invalid request or http status code type: number example: 404 message: description: Message describing error type: string stacktraces: description: 'Optional stacktraces, sent when node is in debug mode' type: array items: type: string - example: code: 400 message: 'Invalid state ID: current' '404': description: Either state ID or withdrawal not found content: application/json: schema: allOf: - type: object properties: code: description: Either specific error code in case of invalid request or http status code type: number example: 404 message: description: Message describing error type: string stacktraces: description: 'Optional stacktraces, sent when node is in debug mode' type: array items: type: string - example: code: 404 message: State ID or withdrawal not found '500': description: Beacon node internal error. content: application/json: schema: type: object properties: code: description: Either specific error code in case of invalid request or http status code type: number example: 404 message: description: Message describing error type: string stacktraces: description: 'Optional stacktraces, sent when node is in debug mode' type: array items: type: string example: code: 500 message: Internal server error

Example implementation, for general proof methods check Merkle proof formats spec:

def get_withdrawal(state: BeaconState, block: BeaconBlock, validator_index: ValidatorIndex): assert state.hash_tree_root() == block.state_root withdrawals = [ (i, w) for i, w in enumerate(state.withdrawals) if w.validator_index == validator_index] if withdrawals: index = withdrawals[-1][0] withdrawal = withdrawals[-1][1] state_index = get_generalized_index(BeaconBlock, ['state_root']) withdrawal_index = get_generalized_index(BeaconState, ['withdrawals', index]) return { 'root': block.hash_tree_root(), 'slot': block.slot, 'proof': [block.get_backing().getter(index).merkle_root() for index in get_helper_indices([state_index])] + [state.get_backing().getter(index).merkle_root() for index in get_helper_indices([withdrawal_index])], 'index': concat_generalized_indices(state_index, withdrawal_index), 'withdrawal': withdrawal } else: return None

Partial withdrawals ^

Let's check how difficult it could be to add partial withdrawals at the top of a full withdrawal scheme. Ability to process partial withdrawals should be examined, but there is a big demand for their implementation.

New operation and signed message is proposed for Eth2, BLSWithdrawRewards, which could inquire for rewards withdrawal, it requires Validator's BLS withdrawal credentials:

class BLSWithdrawRewards(Container):
    withdrawal_credentials: Bytes32  # Withdrawal credentials for reward withdrawal
    withdrawal_epoch: Epoch # Epoch at which message should be processed
    validator_index: ValidatorIndex

And SignedBLSWithdrawRewards message:

class SignedBLSWithdrawRewards(Container):
    message: BLSWithdrawRewards
    signature: BLSSignature  # Signed by withdrawal BLS key    	

Domain:

Name Value
DOMAIN_REWARDS_WITHDRAWAL DomainType('0x07000000')
And withdrawal delay to be sure we are not spammed with withdrawal messages:
TODO: why this delay??
Name Value
- -
MIN_VALIDATOR_REWARDS_WITHDRAWAL_DELAY uint64(2**14) (= 16,384)

To initiate rewards withdrawal, Validator sends appropriate SignedBLSWithdrawRewards message. Next we have processing like this:

def process_rewards_withdrawal(state: BeaconState, signed_reward_withdrawal: SignedBLSWithdrawRewards) -> None: """ Processing SignedBLSWithdrawRewards with rewards withdrawal order """ rewards_withdrawal = signed_reward_withdrawal.message validator = state.validators[rewards_withdrawal.validator_index] # Verify the validator is active assert is_active_validator(validator, get_current_epoch(state)) # Verify exit has not been initiated assert validator.exit_epoch == FAR_FUTURE_EPOCH # Withdrawals must specify an epoch when they become valid; they are not valid before then assert get_current_epoch(state) >= rewards_withdrawal.withdrawal_epoch # Verify the validator has been active long enough assert get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD # Verify signature domain = get_domain(state, DOMAIN_REWARDS_WITHDRAWAL, rewards_withdrawal.withdrawal_epoch) signing_root = compute_signing_root(rewards_withdrawal, domain) assert bls.Verify(validator.pubkey, signing_root, signed_rewards_withdrawal.signature) # Initiate rewards withdrawal initiate_rewards_withdrawal(state, rewards_withdrawal.validator_index) def initiate_rewards_withdrawal(state: BeaconState, message: BLSWithdrawRewards) -> None: """ Initiate rewards withdrawal for the validator with index ``index``. """ # Return if validator already initiated exit validator = state.validators[message.validator_index] if validator.exit_epoch != FAR_FUTURE_EPOCH: return # Compute rewards value rewards_value = state.balances[message.validator_index] - MAX_EFFECTIVE_BALANCE if rewards_value <= 0: return # Check that there were no withdrawals recently if validator.withdrawn_epoch + MIN_VALIDATOR_REWARDS_WITHDRAWAL_DELAY < get_current_epoch(state): return # Process partial withdrawal process_validator_rewards_withdrawal(Byte1(message.withdrawal_credentials[:1]))(state, message) def process_validator_rewards_withdrawal(prefix: Byte1): """ Returns rewards withdrawal function for provided withdrawal_credentials prefix """ if prefix == ETH1_ADDRESS_WITHDRAWAL_PREFIX: return process_eth1_rewards_withdrawal else: return lambda state, message: None def process_eth1_rewards_withdrawal(state: BeaconState, message: BLSWithdrawRewards) -> None: """ Process rewards withdrawal for withdrawal_credentials with Eth1 prefix """ index = message.validator_index validator = state.validators[index] epoch = get_current_epoch(state) # Assert it's Eth1 withdawal assert ETH1_ADDRESS_WITHDRAWAL_PREFIX == Byte1(message.withdrawal_credentials[:1]) # Assert we have something to withdraw withdrawal_value = state.balances[index] - MAX_EFFECTIVE_BALANCE assert withdrawal_value > 0 # Make withdrawal receipt and add it to withdrawals withdrawal = Withdrawal( validator_index: index, withdrawal_credentials: message.withdrawal_credentials, withdrawn_epoch: epoch, amount: withdrawal_value ) state.withdrawals.append(withdrawal) # Update balance and withdrawn epoch state.validators[index].withdrawn_epoch = epoch state.balances[index] = MAX_EFFECTIVE_BALANCE

There are several uncertainties in this implementation, especially with dual usage of withdrawn_epoch field, which is used both for exit withdrawals and rewards withdrawals. It could be solved with 2 fields and extra dedicated rewards_withdrawn_epoch field to make both types of withdrawal less tricky.

The main downside of this implementation is inability to process rewards withdrawals for Validators with non-BLS prefixed withdrawal_credentials, necessity to sign messages with BLS withdrawal key, which is difficult to be shared and inability to restrict withdrawal targets in this case. Most of these disadvantages could be solved with implementation of several Validator's keys as initially proposed by @vbuterin in Adding PoS validator key changes (see "Control of key switching rights" section).

Queues ^

TODO
Withdrawals by validator resign or non-automatic partial withdrawals requires message for each action. All messages are bounded by processing queues limits. Estimates predicts about 10 months of re-entrance queues after withdrawals are enabled, which is a serious issue and should be avoided. So, withdrawals design should be modified to avoid queues both in regular flow and decrease estimated queues or make a special case on withdrawals enable fork.

Changes in ETH1 ^

Changes to opcodes ^

We require only BEACONBLOCKROOT opcode for withdrawal operation.
beaconblockroot(<Beacon block/slot number>) returns root of appropriate BeaconChain block or 0x00*32 if data is not available.

System contract in Eth1 ^

At address 0x00……ff (TO BE DEFINED), add a system contract that expects input in the following format:

RLP(withdrawal: Withdrawal, proof: [Bytes32], index: UInt64, slot: UInt64)

After execution contract pushes the result to the stack, one of:

0x00..00 - FAILED
0x00..01 - SUCCESSFUL
0x00..02 - ALREADY_WITHDRAWN

System contract in Eth1 should consists of 3 parts:

  • verification of proof
  • saving withdrawal claim
  • ether mint

Verification of proof should look something like this (example in python language, from Merkle Proof formats):

def verify_withdrawal(withdrawal: Withdrawal, proof: Sequence[Bytes32], index: GeneralizedIndex, slot: UInt64) -> bool: return verify_merkle_proof(hash_tree_root(withdrawal), proof, index) == op_beacon_block_root(slot) def verify_merkle_proof(leaf: Bytes32, proof: Sequence[Bytes32], index: GeneralizedIndex, root: Root) -> bool: return calculate_merkle_root(leaf, proof, index) == root def calculate_merkle_root(leaf: Bytes32, proof: Sequence[Bytes32], index: GeneralizedIndex) -> Root: assert len(proof) == get_generalized_index_length(index) for i, h in enumerate(proof): if get_generalized_index_bit(index, i): leaf = hash(h + leaf) else: leaf = hash(leaf + h) return leaf def get_generalized_index_length(index: GeneralizedIndex) -> int: """ Return the length of a path represented by a generalized index. """ return int(log2(index)) def get_generalized_index_bit(index: GeneralizedIndex, position: int) -> bool: """ Return the given bit of a generalized index. """ return (index & (1 << position)) > 0

After that we should save in contract storage consumption of this withdrawal receipt, so it couldn't be claimed anymore. We use withdrawal_hash_tree_root = hash_tree_root(withdrawal) for unique reference:

//** Solidity code **// // Definition mapping(bytes32 => bool) withdrawn; // Assertion at the beginning of withdrawal processing require(!withdrawn[withdrawal_hash_tree_root]); // Successful withdrawal claim saved withdrawn[withdrawal_hash_tree_root] = true;

and after that we "mint ether" like we do it for coinbase rewards (trinity codebase example):

def mint_for_withdrawal(withdrawal: Withdrawal) -> None: withdrawal_target = Address(decode_hex(withdrawal.withdrawal_credentials[12:])) withdrawal_amount = withdrawal.amount * denoms.gwei self.vm_state.account_db.delta_balance(withdrawal_target, withdrawal_amount)

Discussion ^

There are number of design limitations if we want to use BeaconChain (ETH2) data in Execution Layer (ETH1), which were reviewed making this specification:

  • we could not expect tight coupling of BeaconChain and Execution Layer clients in the near term and from architecture viewpoint we should avoid it
  • most VM opcodes are executed in nanosecond/microsecond times, and we should reduce this period, no JSON-RPC or similar kind of interactions could fit in it
  • any interaction between several pieces of software leads to sync choice and issues

It leads us to the safest way of interaction: provide some piece of data (several KBs max) with every query on block construction. Check Rayonism merge specification#Consensus JSON RPC for preliminary proposal on ETH2<->ETH1 JSON-RPC interaction protocol. BeaconState does not fit in it, BeaconState changes require stateful interaction. Even several parts of BeaconState are way too big for it. What we should definitely fit in it is BeaconState root, or, more precisely BeaconBlock root to formalize BeaconState view momentum. For more on ETH1<->ETH2 interaction, check Executable Beacon Chain by @mkalinin.

Also there are several topics which should be solved in future but not yet covered in this specification:

  • Burn deposits
    In order to make correct deposit processing when putting money in Eth2 and later out of, we should adjust Deposit contract balance. We could do it either when Deposit is processed and money arrives in BeaconChain, or when cashout is processed. Burning it after processing looks like a better way as we could add several deposit and withdrawal options in the future and ethers do not actually exist in Eth1 after deposit is processed. Finally, we could have a penalty which will burn the whole validator’s stack in the future and the validator will not be able to make withdrawal at all, so the second option looks less viable.
  • Withdrawal list purge
    With the growing number of withdrawals BeaconState will grow over time. We could purge old withdrawals not affecting proof generation by collapsing old tree paths, the only caveat is that we could not (and should not) use data whether this withdrawal was already claimed or not, instead we are able only to define age or list quantity expiration thresholds. It should not be an issue for anyone as these limits could be defined per client and if someone wants to keep all withdrawals since the network establishing he would have no issues with it and any historical withdrawal proof will be legit at any time.
  • Withdrawal versioning
    Later we may want to introduce partial withdrawals or Validator's balance withdrawal will require additional fields, for example, for L2 withdrawals. It will require changes in Withdrawal object and handling of all versions in all participating parties including Eth1 system contract. Appropriate mechanism should be developed.
  • Partial withdrawals
    With such withdrawal claim design we could have partial withdrawals with a few changes on top.
  • Good to have Eth1 features (useful for contract development in connection with Eth2)
    • bls.verify, using EIP-2537 or similar. It will help contracts to verify Eth2 credentials.
    • system contract providing utilities with Eth2 constants and support (ie updating and following all forks). Example: seconds to epochs calculation.