--- tags: Oracle, withdrawals title: Ejector oracle --- # Ejector oracle ## Main cycle The oracle collects data on the current state, and in the loop finds one most suitable validator to exit, then mutates the state simulating this validator's exit and repeats the loop until the number of withdrawals is enough. ```python def get_validators_to_exit(report): state = collect_state(report) if is_exits_paused(state): return [] if is_enough_eth_to_cover_demand(state, 0): return [] validators_ids_to_exit = [] while len(validators_ids_to_exit) < state["max_validators_to_exit"]: if not has_validators_to_exit(state): break predicted_eth = predict_available_eth_before_next_withdrawn(state, report) if is_enough_eth_to_cover_demand(state, predicted_eth): break validator_to_exit = get_next_validator_to_exit(state, report) validators_ids_to_exit.append(get_validator_ids(validator_to_exit)) simulate_validator_exit(state, validator_to_exit) return validators_ids_to_exit ``` ## State collection To find the next validators to exit, Ejector Oracle should first collect the following state from Consensus and Execution layers. - From Consensus Layer node: - All validators and their states on the reference slot - From Staking Router: - Public keys of all Lido validators - Indices of the last requested validator to exit for each Node Operator - Validator keys statistics for each Node Operator - From Withdrawal Queue contract: - Uncovered withdrawal requests demand - From Oracle contract: - Maximum number of exit requests for the current frame - Recently requested via Exit Bus public keys to exit - From Lido contract: - Recent withdrawals from Execution Layer Rewards and Withdrawal vaults ```python def collect_state(report): # Fetch data from Consensus Layer node all_validators = fetch_all_validators(report.ref_slot) # Fetch data from Keys API used_lido_pubkeys_by_modules = fetch_used_lido_keys_by_modules(report.ref_block) lido_modules_dict, lido_pubkeys_dict = build_lido_keys_indexes(used_lido_pubkeys_by_modules) # Fetch data from Staking Router contract operators_keys_stats = fetch_operators_keys_stats(lido_modules_dict) # Fetch data from Withdrawal Queue contract withdrawal_requests_uncovered_demand = fetch_withdrawal_requests_uncovered_demand(report.ref_block) # Fetch data from Oracle contract max_validators_to_exit = fetch_max_validators_to_exit(report.ref_block) recently_requested_pubkeys_to_exit = fetch_recently_requested_pubkeys_to_exit(report.ref_block) last_requested_validators_indices = fetch_last_requested_validators_indices(lido_modules_dict) # Fetch data from Lido contract predicted_pessimistic_rewards_per_epoch = fetch_predicted_pessimistic_rewards_per_epoch(report.ref_block) # Filter validators lido_validators = filter_lido_validators(all_validators, lido_pubkeys_dict) exitable_lido_validators = filter_exitable_lido_validators(report.ref_epoch, lido_validators, lido_pubkeys_dict, last_requested_validators_indices) inflight_lido_validators = filter_inflight_lido_validators(report.ref_epoch, lido_validators, lido_pubkeys_dict, last_requested_validators_indices) # Collect Staking Router state operators_delays = get_operators_delays_dict(lido_validators, last_requested_validators_indices, recently_requested_pubkeys_to_exit, lido_pubkeys_dict) operators_state = collect_operators_state(report.ref_epoch, lido_validators, last_requested_validators_indices, operators_delays, lido_pubkeys_dict, operators_keys_stats) # Collect CL state sweep_duration_epochs = predict_average_sweep_duration_epochs(all_validators) total_projected_validators = get_total_projected_validators(all_validators, lido_pubkeys_dict, last_requested_validators_indices) churn_limit = get_validator_churn_limit(all_validators, report.ref_epoch) exit_epochs = get_max_exit_epochs(all_validators) return { "all_validators": all_validators, "exitable_lido_validators": exitable_lido_validators, "inflight_lido_validators": inflight_lido_validators, "withdrawal_requests_uncovered_demand": withdrawal_requests_uncovered_demand, "max_validators_to_exit": max_validators_to_exit, "predicted_pessimistic_rewards_per_epoch": predicted_pessimistic_rewards_per_epoch, "lido_modules_dict": lido_modules_dict, "lido_pubkeys_dict": lido_pubkeys_dict, "operators_state": operators_state, "operators_keys_stats": operators_keys_stats, "sweep_duration_epochs": sweep_duration_epochs, "total_projected_validators": total_projected_validators, "churn_limit": churn_limit, "exit_epochs": exit_epochs, } ``` ### Fetching data #### Validators from CL Oracle fetches all validators from CL at the reference slot. ```python def fetch_all_validators(ref_slot): pass ``` #### Lido Keys Fetches all used Lido keys from [Keys API](https://github.com/lidofinance/lido-keys-api). ```python def fetch_used_lido_keys_by_modules(ref_block): (data, meta) = fetch_keys_from_api_provider() assert meta["block_number"] > ref_block, "The data is out of date" return data ``` #### Operators keys stats Fetches operators' keys statistics for each Staking Module. ```python def fetch_operators_keys_stats(lido_modules_dict): """ function getNodeOperatorReports( uint256 _stakingModuleId, uint256[] memory _nodeOperatorIds ) """ pass ``` #### Withdrawal requests uncovered demand Collects data on the available ETH in the protocol buffer, in Withdrawal and Execution Layer Rewards vaults, the number of ETH required to cover withdrawal requests and returns the size of the uncovered balance. ```python def fetch_withdrawal_requests_uncovered_demand(ref_block): """ Pessimistic assumption that requests are finalized 1 to 1, without taking into account a discount """ withdrawal_requests_demand = fetch_withdrawal_requests_demand(ref_block) withdrawal_vault_balance = fetch_withdrawal_vault_balance(ref_block) el_rewards_vault_balance = fetch_el_rewards_vault_balance(ref_block) buffered_eth = fetch_buffered_ether(ref_block) reserved_buffered_eth = min(withdrawal_requests_demand, buffered_eth) available_eth_to_cover_demand = withdrawal_vault_balance + el_rewards_vault_balance + reserved_buffered_eth return max(withdrawal_requests_demand - available_eth_to_cover_demand, 0) ``` #### Report limits Fetches report limits from the `OracleReportSanityChecker`. ```python def fetch_max_validators_to_exit(ref_block): """ function getOracleReportLimits() public view returns (LimitsList memory) returns limits["maxValidatorExitRequestsPerReport] """ pass ``` #### Recently requested pubkeys to exit Fetches recently emitted `ValidatorExitRequest` events from `ValidatorsExitBusOracle` contract and extract pubkeys from them. The delayed timeout config fetches from the `OracleDaemonConfig` contract. ==TODO: image for missed blocks== ```python def fetch_recently_requested_pubkeys_to_exit(ref_block): timeout = fetch_delayed_validator_timeout(ref_block) # Rough estimate, since there could be missing blocks in the slots max_period_block_range = timeout / SECONDS_PER_SLOT from_block = ref_block - max_period_block_range to_block = ref_block events = fetch_exit_requests_events(from_block, to_block) # Narrow the events range events = filter_events_by_timestamp(events) return get_pubkeys_set_from_events(events) ``` #### Predicted rewards per epoch Fetches `ETHDistributed` and `TokenRebased` events from Lido contract and calculate average rewards amount per epoch. The rewards prediction period config fetches from the `OracleDaemonConfig` contract. ==TODO: take into account the time between reports since there may be missed reports== ```python def fetch_predicted_pessimistic_rewards_per_epoch(ref_block): period = fetch_rewards_prediction_period(ref_block) # Rough estimate, since there could be missing blocks in the slots max_period_block_range = period / SECONDS_PER_SLOT from_block = ref_block - max_period_block_range to_block = ref_block events = fetch_eth_distributed_events(from_block, to_block) # Narrow the events range events = filter_events_by_timestamp(events) return calc_average_rewards_per_epoch_from_events(events) ``` #### Last requested validators indices The Withdrawal Queue contract stores the index of the last validator that was requested to exit. Since validators are requested in strict order from the lowest validatorIndex to the highest, the indexes help find all the previously requested validators without fetching all events. ==TODO: validator indexes may be reused in the future== ```python def fetch_last_requested_validators_indices(lido_modules_dict): """ function getLastRequestedValidatorIndices(uint256 moduleId, uint256[] calldata nodeOpIds) external view returns (int256[] memory) """ pass ``` #### Withdrawal requests demand ```python def fetch_withdrawal_requests_demand(ref_block): """ Fetches unfinalized steth from Withdrawal Queue """ pass ``` ### Lido keys indexes #### Build Lido keys indexes Returns dicts to identify Lido validators by pubkeys and `module_id` and `operator_id` by validator pubkey. ```python def build_lido_keys_indexes(used_lido_pubkeys_by_modules): lido_modules_dict = get_lido_modules_dict(used_lido_pubkeys_by_modules) lido_pubkeys_dict = get_lido_pubkeys_dict(used_lido_pubkeys_by_modules) return (lido_modules_dict, lido_pubkeys_dict) ``` #### Lido keys dicts ```python def get_lido_modules_dict(keys_by_modules): modules = {} for module in keys_by_modules: module_id = module["module"]["id"] module_address = module["module"]["stakingModuleAddress"] modules[module_id] = { "address": module_address, "operators": set() } for key in module["keys"]: modules[module_id].add(key["operatorIndex"]) return modules def get_lido_pubkeys_dict(keys_by_modules): lido_pubkeys_dict = {} for sr_module in keys_by_modules: module_id = sr_module["module"]["id"] for key in sr_module["keys"]: pubkey = key["key"] operator_id = key["operatorIndex"] lido_pubkeys_dict[pubkey] = (module_id, operator_id) return lido_pubkeys_dict ``` ### Validators filters #### Filter Lido validators ```python def filter_lido_validators(all_validators, lido_pubkeys_dict): return list(filter(lambda validator: (validator["validator"]["pubkey"] in lido_pubkeys_dict), all_validators)) ``` #### Filter active Lido validators ```python def filter_active_lido_validators(epoch, lido_validators): return list(filter(lambda validator: (is_active_validator(validator), epoch), lido_validators)) ``` #### Filter exitable Lido validators ==TODO: exlude validators of inactive operators and stopped modules (do not exclude paused modules)== ```python def filter_exitable_lido_validators(epoch, lido_validators, lido_pubkeys_dict, last_requested_validators_indices): def condition(validator): is_previously_requested = is_previously_requested_to_exit(validator, lido_pubkeys_dict, last_requested_validators_indices) # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#voluntary-exits is_not_exited = validator["validator"]["exit_epoch"] == FAR_FUTURE_EPOCH is_active_long_enough = epoch >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD is_exitable = is_not_exited and is_active_long_enough return not is_previously_requested and is_exitable return list(filter(condition, lido_validators)) ``` #### Filter inflight Lido validators Returns Lido validators that: - Were recently requested to exit and the `delayed` deadline hasn't come yet - Exited but not yet withdrawn ```python def filter_inflight_lido_validators(epoch, lido_validators, lido_pubkeys_dict, last_requested_validators_indices): def condition(validator): is_previously_requested = is_previously_requested_to_exit(validator, lido_pubkeys_dict, last_requested_validators_indices) is_exited = validator["validator"]["exit_epoch"] != FAR_FUTURE_EPOCH is_withdrawn = validator["validator"]["withdrawable_epoch"] <= epoch and validator["balance"] == 0 is_exited_and_withdrawable = is_exited and not is_withdrawn return is_previously_requested or is_exited_and_withdrawable return list(filter(condition, lido_validators)) ``` #### Filter not exited Lido validators Returns requested but not exited validators. In other words, validators that match stuck, delayed, and in flight exit requests. ```python def filter_not_exited_lido_validators(lido_validators, lido_pubkeys_dict, last_requested_validators_indices): def condition(validator): is_previously_requested = is_previously_requested_to_exit(validator, lido_pubkeys_dict, last_requested_validators_indices) is_not_exited = validator["validator"]["exit_epoch"] == FAR_FUTURE_EPOCH return is_previously_requested and is_not_exited return list(filter(condition, lido_validators)) ``` #### Filter delayed Lido validators Returns delayed, including stuck, validators. ```python def filter_delayed_lido_validators(not_exited_lido_validators, recently_requested_pubkeys_to_exit): return list(filter( lambda validator: (validator["validator"]["pubkey"] not in recently_requested_pubkeys_to_exit), not_exited_lido_validators )) ``` #### Misc ```python def get_operator_last_requested_index(validator, lido_pubkeys_dict, last_requested_validators_indices): """ Gets last requested validator index for Node Operator or -1 if there were no exit requests """ pubkey_operator = get_pubkey_operator(validator, lido_pubkeys_dict) return last_requested_validators_indices[pubkey_operator] ``` ```python def get_pubkey_operator(lido_validator, lido_pubkeys_dict): validator_pubkey = lido_validator["validator"]["pubkey"] pubkey_operator = lido_pubkeys_dict[validator_pubkey] assert pubkey_operator is not None, "Not a Lido key" return pubkey_operator ``` ```python def get_validator_ids(lido_validator, lido_pubkeys_dict): module_id, operator_id = get_pubkey_operator(lido_validator, lido_pubkeys_dict) validator_index = lido_validator["index"] return (module_id, operator_id, validator_index) ``` ### Operators state #### Collect operators state Collects the current state of operators: number of exitable keys, delayed validators, target keys to exit, age of validators. ```python def collect_operators_state(ref_epoch, lido_validators, last_requested_validators_indices, operators_delays, lido_pubkeys_dict, operators_keys_stats): operators_dict = {} for validator in lido_validators: pubkey_operator = get_pubkey_operator(validator, lido_pubkeys_dict) is_projected = is_projected_validator(validator, lido_pubkeys_dict, last_requested_validators_indices) if not operators_dict[pubkey_operator]: ever_deposited_keys = get_ever_deposited_keys(operators_keys_stats, pubkey_operator) delayed_validators = get_delayed_validators(operators_delays, operators_keys_stats, pubkey_operator) targeted_validators = get_target_validators(operators_keys_stats, pubkey_operator) operators_dict[pubkey_operator] = { # pre-fill with zero "projected_validators_total_age": 0, # pre-fill with ever deposited keys "projected_validators": ever_deposited_keys, "delayed_validators": delayed_validators, "targeted_validators": targeted_validators, } # subtract pre-fill if the validator exists operators_dict[pubkey_operator]["projected_validators"] -= 1 if is_projected: validator_age = ref_epoch - validator["validator"]["activation_epoch"] operators_dict[pubkey_operator]["projected_validators_total_age"] += validator_age operators_dict[pubkey_operator]["projected_validators"] += 1 return operators_dict ``` #### Delayed validator exits Returns the number of validators that were delayed their exit. ```python def get_delayed_validators(operators_delays, operators_keys_stats, pubkey_operator): operator_stats = operators_keys_stats[pubkey_operator] delayed_validators = operators_delays[pubkey_operator] - operator_stats["refundedKeysKeysCount"] return max(0, delayed_validators) ``` #### Target validators Returns the target number of validators for an operator. ```python def get_target_validators(operators_keys_stats, pubkey_operator): operator_stats = operators_keys_stats[pubkey_operator] is_limit_active = operator_stats["isTargetLimitActive"] target_validators = operator_stats["targetValidatorsCount"] if not is_limit_active: return None return target_validators ``` #### Ever deposited keys ```python def get_ever_deposited_keys(operators_keys_stats, pubkey_operator): operator_stats = operators_keys_stats[pubkey_operator] return operator_stats["validatorsReport"]["totalDeposited"] ``` #### Operator delays Collects dict with delayed keys for each operator ```python def get_operators_delays_dict(lido_validators, last_requested_validators_indices, recently_requested_pubkeys_to_exit, lido_pubkeys_dict): not_exited_lido_validators = filter_not_exited_lido_validators(lido_validators, last_requested_validators_indices) delayed_lido_validators = filter_delayed_lido_validators(not_exited_lido_validators, recently_requested_pubkeys_to_exit) operators_delays = {} for validator in delayed_lido_validators: pubkey_operator = get_pubkey_operator(validator, lido_pubkeys_dict) if not operators_delays[pubkey_operator]: operators_delays[pubkey_operator] = 0 operators_delays[pubkey_operator] += 1 return operators_delays ``` ### Exit queue #### Churn limit Calculates the churn limit for the current state of the chain. The value is not recalculated when the validators exit since the accuracy is sufficient for our purposes. [Churn limit calculation in the spec](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#get_validator_churn_limit) ```python def get_validator_churn_limit(all_validators, ref_epoch): total_active_validators = get_total_active_validators(all_validators, ref_epoch) return max(MIN_PER_EPOCH_CHURN_LIMIT, total_active_validators // CHURN_LIMIT_QUOTIENT) ``` #### Exit epochs Returns an array of maximum exit epochs for the current state of the chain. This data is enough not to recalculate the entire validator set on every validator exit cycle ```python def get_max_exit_epochs(all_validators): exit_epochs = [v["validator"]["exit_epoch"] for v in all_validators if v["validator"]["exit_epoch"] != FAR_FUTURE_EPOCH] max_exit_epoch = max(exit_epochs) return [epoch for epoch in exit_epochs if epoch == max_exit_epoch] ``` #### Exit queue epoch Returns the next exit epoch in the exit queue ```python def get_exit_queue_epoch(exit_epochs, churn_limit, ref_epoch): exit_queue_epoch = max(exit_epochs + [compute_activation_exit_epoch(ref_epoch)]) exit_queue_churn = len([epoch for epoch in exit_epochs if epoch == exit_queue_epoch]) if exit_queue_churn >= churn_limit: exit_queue_epoch += 1 return exit_queue_epoch ``` #### Withdrawable queue epoch Returns the next withdrawable epoch in the exit queue ```python def get_withdrawable_queue_epoch(exit_epochs, churn_limit, ref_epoch): return get_exit_queue_epoch(exit_epochs, churn_limit, ref_epoch) + MIN_VALIDATOR_WITHDRAWABILITY_DELAY ``` #### Activation exit epoch [Method from the spec](https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_activation_exit_epoch). Returns the epoch during which validator activations and exits initiated in `epoch` take effect. ```python def compute_activation_exit_epoch(epoch): return epoch + 1 + MAX_SEED_LOOKAHEAD ``` #### Validators ```python def get_total_active_validators(all_validators, ref_epoch): return len(filter(lambda validator: is_active_validator(validator, ref_epoch), all_validators)) def get_total_projected_validators(all_validators, lido_pubkeys_dict, last_requested_validators_indices): return len(filter(lambda validator: is_projected_validator(validator, lido_pubkeys_dict, last_requested_validators_indices), all_validators)) ``` ### Sweep #### Average sweep prediction Predicts the average epochs of the sweep cycle. In the spec: [get expected withdrawals](https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#new-get_expected_withdrawals), [process withdrawals](https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#new-process_withdrawals) ```python def predict_average_sweep_duration_epochs(all_validators, ref_epoch): total_withdrawable_validators = len(filter(lambda validator: ( is_partially_withdrawable_validator(validator["validator"], validator["balance"]) or is_fully_withdrawable_validator(validator["validator"], validator["balance"], ref_epoch) ), all_validators)) epochs_for_whole_sweep_cycle = total_withdrawable_validators / MAX_WITHDRAWALS_PER_PAYLOAD / SLOTS_PER_EPOCH return epochs_for_whole_sweep_cycle // 2 ``` #### Withdrawable validators ```python def has_eth1_withdrawal_credential(validator): """ Check if `validator` has an 0x01 prefixed "eth1" withdrawal credential https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#has_eth1_withdrawal_credential """ return validator.withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX ``` ```python def is_fully_withdrawable_validator(validator, balance, epoch): """ Check if `validator` is fully withdrawable https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#is_fully_withdrawable_validator """ return ( has_eth1_withdrawal_credential(validator) and validator.withdrawable_epoch <= epoch and balance > 0 ) ``` ```python def is_partially_withdrawable_validator(validator, balance): """ Check if `validator` is partially withdrawable https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#is_partially_withdrawable_validator """ has_max_effective_balance = validator.effective_balance == MAX_EFFECTIVE_BALANCE has_excess_balance = balance > MAX_EFFECTIVE_BALANCE return has_eth1_withdrawal_credential(validator) and has_max_effective_balance and has_excess_balance ``` ## Next validator to exit The algorithm for the validators exiting is based on [the algorithm described on the research forum](https://research.lido.fi/t/withdrawals-on-validator-exiting-order/3048#combined-approach-17). The algorithm is supposed to correct the future number of validators for each Node Operator, so we are going to rely on the projected numbers in our calculations. Let's represent the validators and deposits in-flight of one of the Node Operator in the following form, where validators are sorted by their indexes: <img style="margin: 0 0 15px 0" width="500" src="https://hackmd.io/_uploads/rJkE8zLpj.png"/> The algorithm assumes that the oldest validators are exited first. Therefore, we can separate previously requested validators to exit by knowing the index of the last requested. <img style="margin: 0 0 15px 0" width="500" src="https://hackmd.io/_uploads/BJVbwz86s.png"/> If we look closer, each validator has a status. Some validators may be slashed or be exited without an request from the protocol: <img style="margin: 0 0 15px 0" width="500" src="https://hackmd.io/_uploads/BJ2qwfLTo.png"/> Among all validators we are interested in projected. It includes all active validators and in-flight deposits, but excludes validators whose `exit_epoch != FAR_FUTURE_EPOCH` and those validators that were requested to exit. <img style="margin: 0 0 15px 0" width="500" src="https://hackmd.io/_uploads/BJGVlXUpi.png"/> A few hours later: <img style="margin: 0 0 15px 0" width="500" src="https://hackmd.io/_uploads/HkXHx7ITo.png"/> Note that we are looking for a validator to exit only among those that can be exited, while using the projected number of validators, which includes non-existent yet validators. It's only weights, so there is no conflict here. Let's now look at the weights themselves, which will be used in sorting validators in each cycle: ### 1. Delayed validators The number of delayed validators that a Node Operator has. A Node Operator with delayed keys is placed at the end of the exit queue to avoid blocking the queue. ### 2. Targeted validators to exit The number of targeted validators to exit that a Node Operator has. Calculated as `target limit - projected validators`. ### 3. Stake weight If the number of projected validators for a Node Operator exceeds 1% of all validators in the chain, the weight is calculated as the average value of `activation_epoch` of its validators that can be exited. For other validators of Node Operators this weight is constant and equals `Infinity`. The total number of validators in the chain is projected by excluding validators with `exit_epoch != FAR_FUTURE_EPOCH`. ### 4. Number of validators The weight equals the number of projected validators. ### 5. Activation epoch The weight equals validator activation epoch. The earliest validators exit first. ### 6. Validator index Valdiator with the lowest indices exit first. Applies if there are validators activated in the same epoch. ```python def sort_validators_by_exit_order(state): lido_pubkeys_dict = state["lido_pubkeys_dict"] operators_state = state["operators_state"] total_projected_validators = state["total_projected_validators"] def condition(validator): pubkey_operator = get_pubkey_operator(validator, lido_pubkeys_dict) operator = operators_state[pubkey_operator] projected_validators_total_age = operator["projected_validators_total_age"] stake_volume = 100 * operator["projected_validators"] / total_projected_validators stake_volume_weight = projected_validators_total_age if stake_volume > 1 else 0 targeted_validators_to_exit = max(0, operator["projected_validators"] - operator["targeted_validators"]) if operator["targeted_validators"] else 0 # -1 for reverse order return ( operator["delayed_validators"], -1 * targeted_validators_to_exit, stake_volume_weight, -1 * operator["projected_validators"], validator["validator"]["activation_epoch"], validator["index"] ) state["exitable_lido_validators"].sort(condition) ``` ### Sorting example <table style="width:100%;display:table;"> <thead> <tr> <th colspan="4">Node Operator weights</th> <th colspan="2">Validator weights</th> </tr> <tr> <th>Delayed ↓</th> <th>Target ↑</th> <th>Stake & age ↓</th> <th>Validators ↑</th> <th>Activation ↓</th> <th>Index ↓</th> </tr> </thead> <tbody> <tr><td>0</td><td>10</td><td>1000</td><td>7000</td><td>500</td><td>47</td></tr> <tr><td>0</td><td>10</td><td>1000</td><td>7000</td><td>1500</td><td>90</td></tr> <tr><td colspan="6">...</td></tr> <tr><td>0</td><td>2</td><td>1200</td><td>6000</td><td>1000</td><td>50</td></tr> <tr><td>0</td><td>2</td><td>1200</td><td>6000</td><td>1200</td><td>76</td></tr> <tr><td>0</td><td>2</td><td>1200</td><td>6000</td><td>1400</td><td>81</td></tr> <tr><td colspan="6">...</td></tr> <tr><td>0</td><td>0</td><td>998</td><td>7432</td><td>781</td><td>48</td></tr> <tr><td>0</td><td>0</td><td>998</td><td>7432</td><td>990</td><td>49</td></tr> <tr><td>0</td><td>0</td><td>998</td><td>7432</td><td>1003</td><td>52</td></tr> <tr><td colspan="6">...</td></tr> <tr><td>1</td><td>50</td><td>Infinity</td><td>5</td><td>231</td><td>10</td></tr> <tr><td colspan="6">...</td></tr> <tr><td>2</td><td>0</td><td>Infinity</td><td>2</td><td>3210</td><td>1121</td></tr> <tr><td>2</td><td>0</td><td>Infinity</td><td>2</td><td>3210</td><td>1122</td></tr> </tbody> </table> ## Predict available ETH before next withdrawn Predicts how much ETH the protocol will have at the moment when the closest requested validator to exit can be fully exited and withdrawn. The prediction includes: - In-flight ETH from validators that are already exited, but haven't been withdrawn yet - In-flight ETH from validators who have been requested to exit recently, but have not yet changed their status - Predicted Consensus and Execution layers rewards However, this prediction is pessimistic, since it does not include ETH in the buffer, which is also used to cover withdrawal requests. If the available amount of ETH will cover withdrawal requests in the queue, it's not reasonable to exit validators. ```python def predict_available_eth_before_next_withdrawn(state, report): predicted_withdrawal_queue_epoch = get_withdrawable_queue_epoch(state["exit_epochs"], state["churn_limit"], report.ref_epoch) predicted_rewards_eth = predict_rewards_eth_before_next_withdrawn(predicted_withdrawal_queue_epoch, state, report) inflight_validators_eth = get_inflight_validators_eth(predicted_withdrawal_queue_epoch, state) return predicted_rewards_eth + inflight_validators_eth ``` ```python def predict_rewards_eth_before_next_withdrawn(withdrawal_queue_epoch, state, report): pessimistic_rewards_per_epoch = state["predicted_pessimistic_rewards_per_epoch"] sweep_duration_epochs = state["sweep_duration_epochs"] withdrawn_epoch = withdrawal_queue_epoch + sweep_duration_epochs withdrawn_duration_epochs = withdrawn_epoch - report.ref_epoch return withdrawn_duration_epochs * pessimistic_rewards_per_epoch ``` ==TODO: describe filter of slashed validators== ==TODO: put requested but not delayed validators to queue== ```python def get_inflight_validators_eth(withdrawal_queue_epoch, state): inflight_lido_validators = state["inflight_lido_validators"] filtered_validators = filter( lambda validator: (validator["validator"]["withdrawable_epoch"] <= withdrawal_queue_epoch), inflight_lido_validators ) return sum(map(lambda validator: (predict_validator_balance_on_exit(validator)), filtered_validators)) ``` ```python def predict_validator_balance_on_exit(validator): """ Rewards exceeding MAX_EFFECTIVE_BALANCE are skimmed and counted as CL rewards """ return min(validator["balance"], MAX_EFFECTIVE_BALANCE) ``` ## Simulate exit After choosing the validator to exit, we need to simulate its exit in the state, so that in the next cycle it's not counted twice in the Node Operator statistics. ### Simulate validator exit ```python def simulate_validator_exit(state, report, validator_to_exit): initiate_validator_exit(state, report, validator_to_exit) remove_from_exitable_lido_validators(state, validator_to_exit) decrease_withdrawal_requests_uncovered_demand(state, validator_to_exit) remove_from_operators_stats(state, report.ref_epoch validator_to_exit) ``` ### Initiate validator exit Updates the validator's `exit_epoch` and `withdrawable_epoch` and increases the exit queue. ```python def initiate_validator_exit(state, report, validator): # Compute exit queue epoch exit_queue_epoch = get_exit_queue_epoch(state["exit_epochs"], state["churn_limit"], report.ref_epoch) state["exit_epochs"].append(exit_queue_epoch) # Set validator exit epoch and withdrawable epoch validator["exit_epoch"] = exit_queue_epoch validator["withdrawable_epoch"] = validator["exit_epoch"] + MIN_VALIDATOR_WITHDRAWABILITY_DELAY ``` #### Remove from exitable Lido validators ```python def remove_from_exitable_lido_validators(state, validator_to_exit): state["exitable_lido_validators"].remove(validator_to_exit) ``` #### Decrease withdrawal requests uncovered demand ```python def decrease_withdrawal_requests_uncovered_demand(state, validator_to_exit) state["withdrawal_requests_uncovered_demand"] -= predict_validator_balance_on_exit(validator_to_exit) ``` #### Remove from operators statistics ```python def remove_from_operators_stats(state, ref_epoch, validator): pubkey_operator = get_pubkey_operator(validator, state["lido_pubkeys_dict"]) operators_state = state["operators_state"] operator_state = operators_state[pubkey_operator] validator_age = ref_epoch - validator["validator"]["activation_epoch"] operator_state["projected_validators"] -= 1 operator_state["projected_validators_total_age"] -= validator_age ``` ## Predicates ```python def is_not_exited_validator(validator): return validator["validator"]["exit_epoch"] == FAR_FUTURE_EPOCH def is_projected_validator(validator, lido_pubkeys_dict, last_requested_validators_indices): is_previously_requested = is_previously_requested_to_exit(validator, lido_pubkeys_dict, last_requested_validators_indices) is_not_exited = is_not_exited_validator(validator) return not is_previously_requested and is_not_exited def is_active_validator(validator, ref_epoch): return validator["validator"]["activation_epoch"] <= ref_epoch < validator["validator"]["exit_epoch"] def is_previously_requested_to_exit(validator, lido_pubkeys_dict, last_requested_validators_indices): last_requested_validator_index = get_operator_last_requested_index( validator, lido_pubkeys_dict, last_requested_validators_indices ) return validator["index"] <= last_requested_validator_index def is_enough_eth_to_cover_demand(state, eth): return eth >= state["withdrawal_requests_uncovered_demand"] def is_exits_paused(state): return state["max_validators_to_exit"] == 0 def has_validators_to_exit(state): return state["exitable_lido_validators"] > 0 def is_enough_predicted_eth_to_cover_demand(state): return state["withdrawal_requests_uncovered_demand"] == 0 ```