diff --git a/specs/_features/epbs/beacon-chain.md b/specs/_features/epbs/beacon-chain.md new file mode 100644 index 0000000000..4855997599 --- /dev/null +++ b/specs/_features/epbs/beacon-chain.md @@ -0,0 +1,1450 @@ +# ePBS -- The Beacon Chain + +## Table of contents + + + + + +- [Introduction](#introduction) +- [Constants](#constants) + - [Withdrawal prefixes](#withdrawal-prefixes) + - [Slashing flags](#slashing-flags) +- [Configuration](#configuration) + - [Time parameters](#time-parameters) +- [Preset](#preset) + - [Misc](#misc) + - [Domain types](#domain-types) + - [Gwei values](#gwei-values) + - [Time parameters](#time-parameters-1) + - [State list lenghts](#state-list-lenghts) + - [Rewards and penalties](#rewards-and-penalties) + - [Max operations per block](#max-operations-per-block) + - [Incentivization weights](#incentivization-weights) + - [Execution](#execution) +- [Containers](#containers) + - [New containers](#new-containers) + - [`PendingBalanceDeposit`](#pendingbalancedeposit) + - [`PartialWithdrawal`](#partialwithdrawal) + - [`ExecutionLayerWithdrawRequest`](#executionlayerwithdrawrequest) + - [`PayloadAttestationData`](#payloadattestationdata) + - [`PayloadAttestation`](#payloadattestation) + - [`PayloadAttestationMessage`](#payloadattestationmessage) + - [`IndexedPayloadAttestation`](#indexedpayloadattestation) + - [`ExecutionPayloadHeaderEnvelope`](#executionpayloadheaderenvelope) + - [`SignedExecutionPayloadHeaderEnvelope`](#signedexecutionpayloadheaderenvelope) + - [`ExecutionPayloadEnvelope`](#executionpayloadenvelope) + - [`SignedExecutionPayloadEnvelope`](#signedexecutionpayloadenvelope) + - [`InclusionListSummaryEntry`](#inclusionlistsummaryentry) + - [`InclusionListSummary`](#inclusionlistsummary) + - [`SignedInclusionListSummary`](#signedinclusionlistsummary) + - [`InclusionList`](#inclusionlist) + - [Modified containers](#modified-containers) + - [`Validator`](#validator) + - [`BeaconBlockBody`](#beaconblockbody) + - [`ExecutionPayload`](#executionpayload) + - [`ExecutionPayloadHeader`](#executionpayloadheader) + - [`BeaconState`](#beaconstate) +- [Helper functions](#helper-functions) + - [Math](#math) + - [`bit_floor`](#bit_floor) + - [Predicates](#predicates) + - [`is_builder`](#is_builder) + - [`is_eligible_for_activation_queue`](#is_eligible_for_activation_queue) + - [`is_slashed_proposer`](#is_slashed_proposer) + - [`is_slashed_attester`](#is_slashed_attester) + - [Modified `is_slashable_validator`](#modified-is_slashable_validator) + - [Modified `is_fully_withdrawable_validator`](#modified-is_fully_withdrawable_validator) + - [`is_partially_withdrawable_validator`](#is_partially_withdrawable_validator) + - [`is_valid_indexed_payload_attestation`](#is_valid_indexed_payload_attestation) + - [`is_parent_block_full`](#is_parent_block_full) + - [Beacon State accessors](#beacon-state-accessors) + - [Modified `get_eligible_validator_indices`](#modified-get_eligible_validator_indices) + - [`get_ptc`](#get_ptc) + - [`get_payload_attesting_indices`](#get_payload_attesting_indices) + - [`get_indexed_payload_attestation`](#get_indexed_payload_attestation) + - [`get_validator_excess_balance`](#get_validator_excess_balance) + - [Modified `get_validator_churn_limit`](#modified-get_validator_churn_limit) + - [Modified `get_expected_withdrawals`](#modified-get_expected_withdrawals) + - [Beacon state mutators](#beacon-state-mutators) + - [`compute_exit_epoch_and_update_churn`](#compute_exit_epoch_and_update_churn) + - [Modified `initiate_validator_exit`](#modified-initiate_validator_exit) + - [Modified `slash_validator`](#modified-slash_validator) +- [Genesis](#genesis) + - [Modified `initialize_beacon_statre_from_eth1`](#modified--initialize_beacon_statre_from_eth1) +- [Beacon chain state transition function](#beacon-chain-state-transition-function) + - [Epoch processing](#epoch-processing) + - [Modified `process_epoch`](#modified-process_epoch) + - [Helper functions](#helper-functions-1) + - [Modified `process_registry_updates`](#modified-process_registry_updates) + - [`process_pending_balance_deposits`](#process_pending_balance_deposits) + - [Modified `process_effective_balance_updates`](#modified-process_effective_balance_updates) + - [Modified `process_slashings`](#modified-process_slashings) + - [Modified `get_unslashed_attesting_indices`](#modified-get_unslashed_attesting_indices) + - [Execution engine](#execution-engine) + - [Request data](#request-data) + - [New `NewInclusionListRequest`](#new-newinclusionlistrequest) + - [Engine APIs](#engine-apis) + - [New `notify_new_inclusion_list`](#new-notify_new_inclusion_list) + - [Block processing](#block-processing) + - [Modified `process_block_header`](#modified-process_block_header) + - [Modified `process_operations`](#modified-process_operations) + - [Modified Proposer slashings](#modified-proposer-slashings) + - [Modified Attester slashings](#modified-attester-slashings) + - [Modified `process_attestation`](#modified-process_attestation) + - [Modified `get_validator_from_deposit`](#modified-get_validator_from_deposit) + - [Modified `apply_deposit`](#modified-apply_deposit) + - [Payload Attestations](#payload-attestations) + - [Execution Layer Withdraw Requests](#execution-layer-withdraw-requests) + - [Modified `process_withdrawals`](#modified-process_withdrawals) + - [New `verify_execution_payload_header_envelope_signature`](#new-verify_execution_payload_header_envelope_signature) + - [New `process_execution_payload_header`](#new-process_execution_payload_header) + - [New `verify_execution_payload_signature`](#new-verify_execution_payload_signature) + - [New `verify_inclusion_list_summary_signature`](#new-verify_inclusion_list_summary_signature) + - [Modified `process_execution_payload`](#modified-process_execution_payload) + + + + +## Introduction + +This is the beacon chain specification of the enshrined proposer builder separation feature. + +*Note:* This specification is built upon [Deneb](../../deneb/beacon-chain.md) and is under active development. + +This feature adds new staked consensus participants called *Builders* and new honest validators duties called *payload timeliness attestations*. The slot is divided in **four** intervals as opposed to the current three. Honest validators gather *signed bids* from builders and submit their consensus blocks (a `SigneddBeaconBlock`) at the beginning of the slot. At the start of the second interval, honest validators submit attestations just as they do previous to this feature). At the start of the third interval, aggregators aggregate these attestations (exactly as before this feature) and the honest builder reveals the full payload. At the start of the fourth interval, some honest validators selected to be members of the new **Payload Timeliness Committee** attest to the presence of the builder's payload. + +At any given slot, the status of the blockchain's head may be either +- A *full* block from a previous slot (eg. the current slot's proposer did not submit its block). +- An *empty* block from the current slot (eg. the proposer submitted a timely block, but the builder did not reveal the payload on time). +- A full block for the current slot (both the proposer and the builder revealed on time). + +For a further introduction please refer to this [ethresear.ch article](https://ethresear.ch/t/payload-timeliness-committee-ptc-an-epbs-design/16054) + +## Constants + +### Withdrawal prefixes + +| Name | Value | +| - | - | +| `BUILDER_WITHDRAWAL_PREFIX` | `Bytes1('0x0b')` # (New in ePBS) | + +### Slashing flags +| Name | Value | +| - | - | +| `SLASHED_ATTESTER_FLAG_INDEX`| `0` # (New in ePBS)| +| `SLASHED_PROPOSER_FLAG_INDEX`| `1` # (New in ePBS)| + +## Configuration + +### Time parameters + +| Name | Value | Unit | Duration | +| - | - | :-: | :-: | +| `SECONDS_PER_SLOT` | `uint64(16)` | seconds | 16 seconds # (Modified in ePBS) | + +## Preset + +### Misc + +| Name | Value | +| - | - | +| `PTC_SIZE` | `uint64(2**9)` (=512) # (New in ePBS) | + +### Domain types + +| Name | Value | +| - | - | +| `DOMAIN_BEACON_BUILDER` | `DomainType('0x1B000000')` # (New in ePBS)| +| `DOMAIN_PTC_ATTESTER` | `DomainType('0x0C000000')` # (New in ePBS)| + +### Gwei values + +| Name | Value | +| - | - | +| `BUILDER_MIN_BALANCE` | `Gwei(2**10 * 10**9)` = (1,024,000,000,000) # (New in ePBS)| +| `MIN_ACTIVATION_BALANCE` | `Gwei(2**5 * 10**9)` (= 32,000,000,000) # (New in ePBS)| +| `EFFECTIVE_BALANCE_INCREMENT` | `Gwei(2**0 * 10**9)` (= 1,000,000,000) # (New in ePBS)| +| `MAX_EFFECTIVE_BALANCE` | `Gwei(2**11 * 10**9)` = (2,048,000,000,000) # (Modified in ePBS) | + +### Time parameters + +| Name | Value | Unit | Duration | +| - | - | :-: | :-: | +| `MIN_SLOTS_FOR_INCLUSION_LISTS_REQUESTS` | `uint64(2)` | slots | 32 seconds # (New in ePBS) | + +### State list lenghts +| Name | Value | Unit | Duration | +| - | - | :-: | :-: | +| `MAX_PENDING_BALANCE_DEPOSITS` | `uint64(2**20) = 1 048 576` | `PendingBalanceDeposits` | #(New in ePBS) | +| `MAX_PENDING_PARTIAL_WITHDRAWALS` | `uint64(2**20) = 1 048 576` | `PartialWithdrawals` | # (New in ePBS) | + +### Rewards and penalties + +| Name | Value | +| - | - | +| `PROPOSER_EQUIVOCATION_PENALTY_FACTOR` | `uint64(2**2)` (= 4) # (New in ePBS)| + +### Max operations per block + +| Name | Value | +| - | - | +| `MAX_PAYLOAD_ATTESTATIONS` | `2**1` (= 2) # (New in ePBS) | +| `MAX_EXECUTION_LAYER_WITHDRAW_REQUESTS` | `2**4` (= 16) # (New in ePBS) | + +### Incentivization weights + +| Name | Value | +| - | - | +| `PTC_PENALTY_WEIGHT` | `uint64(2)` # (New in ePBS)| + +### Execution +| Name | Value | +| - | - | +| MAX_TRANSACTIONS_PER_INCLUSION_LIST | `2**4` (=16) # (New in ePBS) | +| MAX_GAS_PER_INCLUSION_LIST | `2**21` (=2,097,152) # (New in ePBS) | + +## Containers + +### New containers + +#### `PendingBalanceDeposit` + +```python +class PendingBalanceDeposit(Container): + index: ValidatorIndex + amount: Gwei +``` + +#### `PartialWithdrawal` + +```python +class PartialWithdrawal(Container) + index: ValidatorIndex + amount: Gwei + withdrawable_epoch: Epoch +``` + +#### `ExecutionLayerWithdrawRequest` + +```python +class ExecutionLayerWithdrawRequest(Container) + source_address: ExecutionAddress + validator_pubkey: BLSPubkey + balance: Gwei +``` + +#### `PayloadAttestationData` + +```python +class PayloadAttestationData(Container): + beacon_block_root: Root + slot: Slot + payload_present: bool +``` + +#### `PayloadAttestation` + +```python +class PayloadAttestation(Container): + aggregation_bits: BitVector[PTC_SIZE] + data: PayloadAttestationData + signature: BLSSignature +``` + +#### `PayloadAttestationMessage` + +```python +class PayloadAttestationMessage(Container): + validator_index: ValidatorIndex + data: PayloadAttestationData + signature: BLSSignature +``` + +#### `IndexedPayloadAttestation` + +```python +class IndexedPayloadAttestation(Container): + attesting_indices: List[ValidatorIndex, PTC_SIZE] + data: PayloadAttestationData + signature: BLSSignature +``` + +#### `ExecutionPayloadHeaderEnvelope` + +```python +class ExecutionPayloadHeaderEnvelope(Container): + header: ExecutionPayloadHeader + builder_index: ValidatorIndex + value: Gwei +``` + +#### `SignedExecutionPayloadHeaderEnvelope` + +```python +class SignedExecutionPayloadHeaderEnvelope(Container): + message: ExecutionPayloadHeaderEnvelope + signature: BLSSignature +``` + +#### `ExecutionPayloadEnvelope` + +```python +class ExecutionPayloadEnvelope(Container): + payload: ExecutionPayload + builder_index: ValidatorIndex + beacon_block_root: Root + blob_kzg_commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK] + inclusion_list_proposer_index: ValidatorIndex + inclusion_list_signature: BLSSignature + state_root: Root +``` + +#### `SignedExecutionPayloadEnvelope` + +```python +class SignedExecutionPayloadEnvelope(Container): + message: ExecutionPayloadEnvelope + signature: BLSSignature +``` + +#### `InclusionListSummaryEntry` + +```python +class InclusionListSummaryEntry(Container): + address: ExecutionAddress + gas_limit: uint64 +``` + +#### `InclusionListSummary` + +```python +class InclusionListSummary(Container) + proposer_index: ValidatorIndex + summary: List[InclusionListSummaryEntry, MAX_TRANSACTIONS_PER_INCLUSION_LIST] +``` + +#### `SignedInclusionListSummary` + +```python +class SignedInclusionListSummary(Container): + message: InclusionListSummary + signature: BLSSignature +``` + +#### `InclusionList` + +```python +class InclusionList(Container) + summary: SignedInclusionListSummary + slot: Slot + transactions: List[Transaction, MAX_TRANSACTIONS_PER_INCLUSION_LIST] +``` + +### Modified containers + +#### `Validator` +**Note:** The `Validator` class is modified to keep track of the slashed categories. + +```python +class Validator(Container): + pubkey: BLSPubkey + withdrawal_credentials: Bytes32 # Commitment to pubkey for withdrawals + effective_balance: Gwei # Balance at stake + slashed: uint8 # (Modified in ePBS) + # 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 +``` + + +#### `BeaconBlockBody` +**Note:** The Beacon Block body is modified to contain a Signed `ExecutionPayloadHeader`. The containers `BeaconBlock` and `SignedBeaconBlock` are modified indirectly. + +```python +class BeaconBlockBody(Container): + randao_reveal: BLSSignature + eth1_data: Eth1Data # Eth1 data vote + graffiti: Bytes32 # Arbitrary data + # Operations + proposer_slashings: List[ProposerSlashing, MAX_PROPOSER_SLASHINGS] + attester_slashings: List[AttesterSlashing, MAX_ATTESTER_SLASHINGS] + attestations: List[Attestation, MAX_ATTESTATIONS] + deposits: List[Deposit, MAX_DEPOSITS] + voluntary_exits: List[SignedVoluntaryExit, MAX_VOLUNTARY_EXITS] + sync_aggregate: SyncAggregate + # Execution + # Removed execution_payload [Removed in ePBS] + # Removed blob_kzg_commitments [Removed in ePBS] + bls_to_execution_changes: List[SignedBLSToExecutionChange, MAX_BLS_TO_EXECUTION_CHANGES] + # PBS + signed_execution_payload_header_envelope: SignedExecutionPayloadHeaderEnvelope # [New in ePBS] + payload_attestations: List[PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS] # [New in ePBS] + execution_payload_withdraw_requests: List[ExecutionLayerWithdrawRequest, MAX_EXECUTION_LAYER_WITHDRAW_REQUESTS] # [New in ePBS] + +``` + +#### `ExecutionPayload` + +**Note:** The `ExecutionPayload` is modified to contain a transaction inclusion list summary signed by the corresponding beacon block proposer and the list of indices of transactions in the parent block that have to be excluded from the inclusion list summary because they were satisfied in the previous slot. + +```python +class ExecutionPayload(Container): + # Execution block header fields + parent_hash: Hash32 + fee_recipient: ExecutionAddress # 'beneficiary' in the yellow paper + state_root: Bytes32 + receipts_root: Bytes32 + logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM] + prev_randao: Bytes32 # 'difficulty' in the yellow paper + block_number: uint64 # 'number' in the yellow paper + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + # Extra payload fields + block_hash: Hash32 # Hash of execution block + transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD] + withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + blob_gas_used: uint64 + excess_blob_gas: uint64 + inclusion_list_summary: List[InclusionListSummaryEntry, MAX_TRANSACTIONS_PER_INCLUSION_LIST] # [New in ePBS] + inclusion_list_exclusions: List[uint64, MAX_TRANSACTIONS_PER_INCLUSION_LIST] # [New in ePBS] +``` + +#### `ExecutionPayloadHeader` + +**Note:** The `ExecutionPayloadHeader` is modified to account for the transactions inclusion lists. + +```python +class ExecutionPayloadHeader(Container): + # Execution block header fields + parent_hash: Hash32 + fee_recipient: ExecutionAddress + state_root: Bytes32 + receipts_root: Bytes32 + logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM] + prev_randao: Bytes32 + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + # Extra payload fields + block_hash: Hash32 # Hash of execution block + transactions_root: Root + withdrawals_root: Root + blob_gas_used: uint64 + excess_blob_gas: uint64 + inclusion_list_summary_root: Root # [New in ePBS] + inclusion_list_exclusions_root: Root # [New in ePBS] +``` + +#### `BeaconState` +*Note*: the beacon state is modified to store a signed latest execution payload header, to track the last withdrawals and increased Maximum effective balance fields: `deposit_balance_to_consume`, `exit_balance_to_consume` and `earliest_exit_epoch`. + +```python +class BeaconState(Container): + # Versioning + genesis_time: uint64 + genesis_validators_root: Root + slot: Slot + fork: Fork + # History + latest_block_header: BeaconBlockHeader + block_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT] + state_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT] + historical_roots: List[Root, HISTORICAL_ROOTS_LIMIT] # Frozen in Capella, replaced by historical_summaries + # Eth1 + eth1_data: Eth1Data + eth1_data_votes: List[Eth1Data, EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH] + eth1_deposit_index: uint64 + # Registry + validators: List[Validator, VALIDATOR_REGISTRY_LIMIT] + balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT] + # Randomness + randao_mixes: Vector[Bytes32, EPOCHS_PER_HISTORICAL_VECTOR] + # Slashings + slashings: Vector[Gwei, EPOCHS_PER_SLASHINGS_VECTOR] # Per-epoch sums of slashed effective balances + # Participation + previous_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT] + current_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT] + # Finality + justification_bits: Bitvector[JUSTIFICATION_BITS_LENGTH] # Bit set for every recent justified epoch + previous_justified_checkpoint: Checkpoint + current_justified_checkpoint: Checkpoint + finalized_checkpoint: Checkpoint + # Inactivity + inactivity_scores: List[uint64, VALIDATOR_REGISTRY_LIMIT] + # Sync + current_sync_committee: SyncCommittee + next_sync_committee: SyncCommittee + # Execution + latest_execution_payload_header: ExecutionPayloadHeader + # Withdrawals + next_withdrawal_index: WithdrawalIndex + next_withdrawal_validator_index: ValidatorIndex + # Deep history valid from Capella onwards + historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT] + # PBS + previous_inclusion_list_proposer: ValidatorIndex # [New in ePBS] + latest_inclusion_list_proposer: ValidatorIndex # [New in ePBS] + signed_execution_payload_header_envelope: SignedExecutionPayloadHeaderEnvelope # [New in ePBS] + last_withdrawals_root: Root # [New in ePBS] + deposit_balance_to_consume: Gwei # [New in ePBS] + exit_balance_to_consume: Gwei # [New in ePBS] + earliest_exit_epoch: Epoch # [New in ePBS] + pending_balance_deposits: List[PendingBalanceDeposit, MAX_PENDING_BALANCE_DEPOSITS] # [New in ePBS] + pending_partial_withdrawals: List[PartialWithdrawals, MAX_PENDING_PARTIAL_WITHDRAWALS] # [New in ePBS] +``` + +## Helper functions + +### Math + +#### `bit_floor` + +```python +def bit_floor(n: uint64) -> uint64: + """ + if ``n`` is not zero, returns the largest power of `2` that is not greater than `n`. + """ + if n == 0: + return 0 + return uint64(1) << (n.bit_length() - 1) +``` + +### Predicates + +#### `is_builder` + +```python +def is_builder(validator: Validator) -> bool: + """ + Check if `validator` is a registered builder + """ + return validator.withdrawal_credentials[0] == BUILDER_WITHDRAWAL_PREFIX +``` + +#### `is_eligible_for_activation_queue` + +```python +def is_eligible_for_activation_queue(validator: Validator) -> bool: + """ + Check if ``validator`` is eligible to be placed into the activation queue. + """ + return ( + validator.activation_eligibility_epoch == FAR_FUTURE_EPOCH + and validator.effective_balance >= MIN_ACTIVATION_BALANCE + ) +``` + +#### `is_slashed_proposer` + +```python +def is_slashed_proposer(validator: Validator) -> bool: + """ + return ``true`` if ``validator`` has committed a proposer equivocation + """ + return has_flag(ParticipationFlags(validator.slashed), SLASHED_PROPOSER_FLAG_INDEX) +``` + +#### `is_slashed_attester` + +```python +def is_slashed_attester(validator: Validator) -> bool: + """ + return ``true`` if ``validator`` has committed an attestation slashing offense + """ + return has_flag(ParticipationFlags(validator.slashed), SLASHED_ATTESTSER_FLAG_INDEX) +``` + + +#### Modified `is_slashable_validator` +**Note:** The function `is_slashable_validator` is modified and renamed to `is_attester_slashable_validator`. + +```python +def is_attester_slashable_validator(validator: Validator, epoch: Epoch) -> bool: + """ + Check if ``validator`` is slashable. + """ + return (not is_slashed_attester(validator)) and (validator.activation_epoch <= epoch < validator.withdrawable_epoch) +``` + +#### Modified `is_fully_withdrawable_validator` + +```python +def is_fully_withdrawable_validator(validator: Validator, balance: Gwei, epoch: Epoch) -> bool: + """ + Check if ``validator`` is fully withdrawable. + """ + return ( + (has_eth1_withdrawal_credential(validator) or is_builder(validator)) + and validator.withdrawable_epoch <= epoch + and balance > 0 + ) +``` + +#### `is_partially_withdrawable_validator` + +```python +def is_partially_withdrawable_validator(validator: Validator, balance: Gwei) -> bool: + """ + Check if ``validator`` is partially withdrawable. + """ + if not (has_eth1_withdrawal_credential(validator) or is_builder(validator)): + return False + return get_validator_excess_balance(validator, balance) > 0 +``` + +#### `is_valid_indexed_payload_attestation` + +```python +def is_valid_indexed_payload_attestation(state: BeaconState, indexed_payload_attestation: IndexedPayloadAttestation) -> bool: + """ + Check if ``indexed_payload_attestation`` is not empty, has sorted and unique indices and has a valid aggregate signature. + """ + # Verify indices are sorted and unique + indices = indexed_payload_attestation.attesting_indices + if len(indices) == 0 or not indices == sorted(set(indices)): + return False + # Verify aggregate signature + pubkeys = [state.validators[i].pubkey for i in indices] + domain = get_domain(state, DOMAIN_PTC_ATTESTER, None) + signing_root = compute_signing_root(indexed_payload_attestation.data, domain) + return bls.FastAggregateVerify(pubkeys, signing_root, indexed_payload_attestation.signature) +``` + +#### `is_parent_block_full` + +```python +def is_parent_block_full(state: BeaconState) -> bool: + return state.signed_execution_payload_header_envelope.message.header == state.latest_execution_payload_header +``` + +### Beacon State accessors + +#### Modified `get_eligible_validator_indices` +**Note:** The function `get_eligible_validator_indices` is modified to use the new flag mechanism for slashings. + +```python +def get_eligible_validator_indices(state: BeaconState) -> Sequence[ValidatorIndex]: + previous_epoch = get_previous_epoch(state) + return [ + ValidatorIndex(index) for index, v in enumerate(state.validators) + if is_active_validator(v, previous_epoch) or (is_slashed_attester(v) and previous_epoch + 1 < v.withdrawable_epoch) + ] +``` + +#### `get_ptc` + +```python +def get_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: + """ + Get the ptc committee for the given ``slot`` + """ + epoch = compute_epoch_at_slot(slot) + committees_per_slot = bit_floor(max(get_committee_count_per_slot(state, epoch), PTC_SIZE)) + members_per_committee = PTC_SIZE/committees_per_slot + + validator_indices = [] + for idx in range(committees_per_slot) + beacon_committee = get_beacon_committee(state, slot, idx) + vals = [idx for idx in beacon_committee if not is_builder(idx)] + validator_indices += vals[:members_per_commitee] + return validator_indices +``` + +#### `get_payload_attesting_indices` + +```python +def get_payload_attesting_indices(state: BeaconState, + slot: Slot, payload_attestation: PayloadAttestation) -> Set[ValidatorIndex]: + """ + Return the set of attesting indices corresponding to ``payload_attestation``. + """ + ptc = get_ptc(state, slot) + return set(index for i, index in enumerate(ptc) if payload_attestation.aggregation_bits[i]) +``` + + +#### `get_indexed_payload_attestation` + +```python +def get_indexed_payload_attestation(state: BeaconState, + slot: Slot, payload_attestation: PayloadAttestation) -> IndexedPayloadAttestation: + """ + Return the indexed payload attestation corresponding to ``payload_attestation``. + """ + attesting_indices = get_payload_attesting_indices(state, slot, payload_attestation) + + return IndexedPayloadAttestation( + attesting_indices=sorted(attesting_indices), + data=payload_attestation.data, + signature=payload_attestation.signature, + ) +``` + +#### `get_validator_excess_balance` + +```python +def get_validator_excess_balance(validator: Validator, balance: Gwei) -> Gwei: + if has_eth1_withdrawal_credential(validator) and balance > MIN_ACTIVATION_BALANCE: + return balance - MIN_ACTIVATION_BALANCE + if is_builder(validator) and balance > MAX_EFFECTIVE_BALANCE: + return balance - MAX_EFFECTIVE_BALANCE + return Gwei(0) +``` + +#### Modified `get_validator_churn_limit` + +```python +def get_validator_churn_limit(state: BeaconState) -> Gwei: + """ + Return the validator churn limit for the current epoch. + """ + churn = max(MIN_PER_EPOCH_CHURN_LIMIT * MIN_ACTIVATION_BALANCE, get_total_active_balance(state) // CHURN_LIMIT_QUOTIENT) + return churn - churn % EFFECTIVE_BALANCE_INCREMENT +``` + +#### Modified `get_expected_withdrawals` +**Note:** the function `get_expected_withdrawals` is modified to churn the withdrawals by balance because of the increase in `MAX_EFFECTIVE_BALANCE` + +```python +def get_expected_withdrawals(state: BeaconState) -> Sequence[Withdrawal]: + epoch = get_current_epoch(state) + withdrawal_index = state.next_withdrawal_index + validator_index = state.next_withdrawal_validator_index + withdrawals: List[Withdrawal] = [] + consumed = 0 + for withdrawal in state.pending_partial_withdrawals: + if withdrawal.withdrawable_epoch > epoch or len(withdrawals) == MAX_WITHDRAWALS_PER_PAYLOAD // 2: + break + validator = state.validators[withdrawal.index] + if validator.exit_epoch == FAR_FUTURE_EPOCH and state.balances[withdrawal.index] > MIN_ACTIVATION_BALANCEa: + withdrawble_balance = min(state.balances[withdrawal.index] - MIN_ACTIVATION_BALANCE, withdrawal.amount) + withdrawals.append(Withdrawal( + index=withdrawal_index, + validator_index=withdrawal.index, + address=ExecutionAddress(validator.withdrawal_credentials[12:]), + amount=withdrawable_balance, + )) + withdrawal_index += WithdrawalIndex(1) + consumed += 1 + state.pending_partial_withdrawals = state.pending_partial_withdrawals[consumed:] + + # Sweep for remaining + bound = min(len(state.validators), MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP) + for _ in range(bound): + validator = state.validators[validator_index] + balance = state.balances[validator_index] + if is_fully_withdrawable_validator(validator, balance, epoch): + withdrawals.append(Withdrawal( + index=withdrawal_index, + validator_index=validator_index, + address=ExecutionAddress(validator.withdrawal_credentials[12:]), + amount=balance, + )) + withdrawal_index += WithdrawalIndex(1) + elif is_partially_withdrawable_validator(validator, balance): + withdrawals.append(Withdrawal( + index=withdrawal_index, + validator_index=validator_index, + address=ExecutionAddress(validator.withdrawal_credentials[12:]), + amount=get_validator_excess_balance(validator, balance), + )) + withdrawal_index += WithdrawalIndex(1) + if len(withdrawals) == MAX_WITHDRAWALS_PER_PAYLOAD: + break + validator_index = ValidatorIndex((validator_index + 1) % len(state.validators)) + return withdrawals +``` + +### Beacon state mutators + +#### `compute_exit_epoch_and_update_churn` + +```python +def compute_exit_epoch_and_update_churn(state: BeaconState, exit_balance: Gwei) -> Epoch: + earliest_exit_epoch = compute_activation_exit_epoch(get_current_epoch(state)) + per_epoch_churn = get_validator_churn_limit(state) + # New epoch for exits. + if state.earliest_exit_epoch < earliest_exit_epoch: + state.earliest_exit_epoch = earliest_exit_epoch + state.exit_balance_to_consume = per_epoch_churn + + # Exit fits in the current earliest epoch. + if exit_balance < state.exit_balance_to_consume: + state.exit_balance_to_consume -= exit_balance + else: # Exit doesn't fit in the current earliest epoch. + balance_to_process = exit_balance - state.exit_balance_to_consume + additional_epochs, remainder = divmod(balance_to_process, per_epoch_churn) + state.earliest_exit_epoch += additional_epochs + state.exit_balance_to_consume = per_epoch_churn - remainder + return state.earliest_exit_epoch +``` + +#### Modified `initiate_validator_exit` +**Note:** the function `initiate_validator_exit` is modified to use the new churn mechanism. + +```python +def initiate_validator_exit(state: BeaconState, index: ValidatorIndex) -> None: + """ + Initiate the exit of the validator with index ``index``. + """ + # Return if validator already initiated exit + validator = state.validators[index] + if validator.exit_epoch != FAR_FUTURE_EPOCH: + return + + # Compute exit queue epoch + exit_queue_epoch = compute_exit_epoch_and_update_churn(state, state.balances[index]) + + # Set validator exit epoch and withdrawable epoch + validator.exit_epoch = exit_queue_epoch + validator.withdrawable_epoch = Epoch(validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) +``` + +#### Modified `slash_validator` +**Note:** The function `slash_validator` is modified to use the new flag system. + +```python +def slash_validator(state: BeaconState, + slashed_index: ValidatorIndex, + whistleblower_index: ValidatorIndex=None) -> None: + """ + Slash the validator with index ``slashed_index``. + """ + epoch = get_current_epoch(state) + initiate_validator_exit(state, slashed_index) + validator = state.validators[slashed_index] + validator.slashed = add_flag(validator.slashed, SLASHED_ATTESTER_FLAG_INDEX) + validator.withdrawable_epoch = max(validator.withdrawable_epoch, Epoch(epoch + EPOCHS_PER_SLASHINGS_VECTOR)) + state.slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] += validator.effective_balance + decrease_balance(state, slashed_index, validator.effective_balance // MIN_SLASHING_PENALTY_QUOTIENT) + + # Apply proposer and whistleblower rewards + proposer_index = get_beacon_proposer_index(state) + if whistleblower_index is None: + whistleblower_index = proposer_index + whistleblower_reward = Gwei(validator.effective_balance // WHISTLEBLOWER_REWARD_QUOTIENT) + proposer_reward = Gwei(whistleblower_reward // PROPOSER_REWARD_QUOTIENT) + increase_balance(state, proposer_index, proposer_reward) + increase_balance(state, whistleblower_index, Gwei(whistleblower_reward - proposer_reward)) +``` + +## Genesis + +### Modified `initialize_beacon_statre_from_eth1` + +```python +def initialize_beacon_state_from_eth1(eth1_block_hash: Hash32, + eth1_timestamp: uint64, + deposits: Sequence[Deposit]) -> BeaconState: + fork = Fork( + previous_version=GENESIS_FORK_VERSION, + current_version=GENESIS_FORK_VERSION, + epoch=GENESIS_EPOCH, + ) + state = BeaconState( + genesis_time=eth1_timestamp + GENESIS_DELAY, + fork=fork, + eth1_data=Eth1Data(block_hash=eth1_block_hash, deposit_count=uint64(len(deposits))), + latest_block_header=BeaconBlockHeader(body_root=hash_tree_root(BeaconBlockBody())), + randao_mixes=[eth1_block_hash] * EPOCHS_PER_HISTORICAL_VECTOR, # Seed RANDAO with Eth1 entropy + ) + + # Process deposits + leaves = list(map(lambda deposit: deposit.data, deposits)) + for index, deposit in enumerate(deposits): + deposit_data_list = List[DepositData, 2**DEPOSIT_CONTRACT_TREE_DEPTH](*leaves[:index + 1]) + state.eth1_data.deposit_root = hash_tree_root(deposit_data_list) + process_deposit(state, deposit) + + # Process activations + for index, validator in enumerate(state.validators): + balance = state.balances[index] + validator.effective_balance = min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, MAX_EFFECTIVE_BALANCE) + if validator.effective_balance >= MIN_ACTIVATION_BALANCE: + validator.activation_eligibility_epoch = GENESIS_EPOCH + validator.activation_epoch = GENESIS_EPOCH + + # Set genesis validators root for domain separation and chain versioning + state.genesis_validators_root = hash_tree_root(state.validators) + + return state +``` + +## Beacon chain state transition function + +*Note*: state transition is fundamentally modified in ePBS. The full state transition is broken in two parts, first importing a signed block and then importing an execution payload. + +The post-state corresponding to a pre-state `state` and a signed block `signed_block` is defined as `state_transition(state, signed_block)`. State transitions that trigger an unhandled exception (e.g. a failed `assert` or an out-of-range list access) are considered invalid. State transitions that cause a `uint64` overflow or underflow are also considered invalid. + +The post-state corresponding to a pre-state `state` and a signed execution payload `signed_execution_payload` is defined as `process_execution_payload(state, signed_execution_payload)`. State transitions that trigger an unhandled exception (e.g. a failed `assert` or an out-of-range list access) are considered invalid. State transitions that cause a `uint64` overflow or underflow are also considered invalid. + +### Epoch processing + +#### Modified `process_epoch` + +```python +def process_epoch(state: BeaconState) -> None: + process_justification_and_finalization(state) + process_inactivity_updates(state) + process_rewards_and_penalties(state) + process_registry_updates(state) # [Modified in ePBS] + process_slashings(state) + process_eth1_data_reset(state) + process_pending_balance_deposits(state) # [New in ePBS] + process_effective_balance_updates(state) # [Modified in ePBS] + process_slashings_reset(state) # [Modified in ePBS] + process_randao_mixes_reset(state) + process_historical_summaries_update(state) + process_participation_flag_updates(state) + process_sync_committee_updates(state) + process_builder_updates(state) # [New in ePBS] +``` + +#### Helper functions + +##### Modified `process_registry_updates` + +```python +def process_registry_updates(state: BeaconState) -> None: + # Process activation eligibility and ejections + for index, validator in enumerate(state.validators): + if is_eligible_for_activation_queue(validator): + validator.activation_eligibility_epoch = get_current_epoch(state) + 1 + + if ( + is_active_validator(validator, get_current_epoch(state)) + and validator.effective_balance <= EJECTION_BALANCE + ): + initiate_validator_exit(state, ValidatorIndex(index)) + + # Activate all eligible validators + activation_epoch = compute_activation_exit_epoch(get_current_epoch(state)) + for validator in state.validators: + if is_eligible_for_activation(state, validator): + validator.activation_epoch = activation_epoch +``` + +##### `process_pending_balance_deposits` + +```python +def process_pending_balance_deposits(state: BeaconState) -> None: + state.deposit_balance_to_consume += get_validator_churn_limit(state) + next_pending_deposit_index = 0 + for pending_balance_deposit in state.pending_balance_deposits: + if state.deposit_balance_to_consume < pending_balance_deposit.amount: + break + + state.deposit_balance_to_consume -= pending_balance_deposit.amount + increase_balance(state, pending_balance_deposit.index, pending_balance_deposit.amount) + next_pending_deposit_index += 1 + + state.pending_balance_deposits = state.pending_balance_deposits[next_pending_deposit_index:] +``` + +##### Modified `process_effective_balance_updates` + +```python +def process_effective_balance_updates(state: BeaconState) -> None: + # Update effective balances with hysteresis + for index, validator in enumerate(state.validators): + balance = state.balances[index] + HYSTERESIS_INCREMENT = uint64(EFFECTIVE_BALANCE_INCREMENT // HYSTERESIS_QUOTIENT) + DOWNWARD_THRESHOLD = HYSTERESIS_INCREMENT * HYSTERESIS_DOWNWARD_MULTIPLIER + UPWARD_THRESHOLD = HYSTERESIS_INCREMENT * HYSTERESIS_UPWARD_MULTIPLIER + EFFECTIVE_BALANCE_LIMIT = MAX_EFFECTIVE_BALANCE if is_builder(validator) else MIN_ACTIVATION_BALANCE + if ( + balance + DOWNWARD_THRESHOLD < validator.effective_balance + or validator.effective_balance + UPWARD_THRESHOLD < balance + ): + validator.effective_balance = min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, EFFECTIVE_BALANCE_LIMIT) +``` + +##### Modified `process_slashings` +**Note:** The only modification is to use the new flag mechanism + +```python +def process_slashings(state: BeaconState) -> None: + epoch = get_current_epoch(state) + total_balance = get_total_active_balance(state) + adjusted_total_slashing_balance = min(sum(state.slashings) * PROPORTIONAL_SLASHING_MULTIPLIER, total_balance) + for index, validator in enumerate(state.validators): + if is_slashed_attester(validator) and epoch + EPOCHS_PER_SLASHINGS_VECTOR // 2 == validator.withdrawable_epoch: + increment = EFFECTIVE_BALANCE_INCREMENT # Factored out from penalty numerator to avoid uint64 overflow + penalty_numerator = validator.effective_balance // increment * adjusted_total_slashing_balance + penalty = penalty_numerator // total_balance * increment + decrease_balance(state, ValidatorIndex(index), penalty) +``` + +##### Modified `get_unslashed_attesting_indices` +**Note:** The function `get_unslashed_attesting_indices` is modified to return only the attester slashing validators. + +```python +def get_unslashed_participating_indices(state: BeaconState, flag_index: int, epoch: Epoch) -> Set[ValidatorIndex]: + """ + Return the set of validator indices that are both active and unslashed for the given ``flag_index`` and ``epoch``. + """ + assert epoch in (get_previous_epoch(state), get_current_epoch(state)) + if epoch == get_current_epoch(state): + epoch_participation = state.current_epoch_participation + else: + epoch_participation = state.previous_epoch_participation + active_validator_indices = get_active_validator_indices(state, epoch) + participating_indices = [i for i in active_validator_indices if has_flag(epoch_participation[i], flag_index)] + return set(filter(lambda index: not is_slashed_attester(state.validators[index]), participating_indices)) +``` + +### Execution engine + +#### Request data + +##### New `NewInclusionListRequest` + +```python +@dataclass +class NewInclusionListRequest(object): + inclusion_list: List[Transaction, MAX_TRANSACTIONS_PER_INCLUSION_LIST] + summary: List[InclusionListSummaryEntry, MAX_TRANSACTIONS_PER_INCLUSION_LIST] + parent_block_hash: Hash32 +``` + +#### Engine APIs + +#### New `notify_new_inclusion_list` + +```python +def notify_new_inclusion_list(self: ExecutionEngine, + inclusion_list_request: NewInclusionListRequest) -> bool: + """ + Return ``True`` if and only if the transactions in the inclusion list can be succesfully executed + starting from the execution state corresponding to the `parent_block_hash` in the inclusion list + summary. The execution engine also checks that the total gas limit is less or equal that + ```MAX_GAS_PER_INCLUSION_LIST``, and the transactions in the list of transactions correspond to the signed summary + """ + ... +``` + +### Block processing + +*Note*: the function `process_block` is modified to only process the consensus block. The full state-transition process is broken into separate functions, one to process a `BeaconBlock` and another to process a `SignedExecutionPayload`. Notice that withdrawals are now included in the beacon block, they are processed before the execution payload header as this header may affect validator balances. + + +```python +def process_block(state: BeaconState, block: BeaconBlock) -> None: + process_block_header(state, block) # [Modified in ePBS] + process_withdrawals(state) # [Modified in ePBS] + process_execution_payload_header(state, block) # [Modified in ePBS, removed process_execution_payload] + process_randao(state, block.body) + process_eth1_data(state, block.body) + process_operations(state, block.body) # [Modified in ePBS] + process_sync_aggregate(state, block.body.sync_aggregate) +``` + +#### Modified `process_block_header` +**Note:** the only modification is in the `slashed` verification. + +```python +def process_block_header(state: BeaconState, block: BeaconBlock) -> None: + # Verify that the slots match + assert block.slot == state.slot + # Verify that the block is newer than latest block header + assert block.slot > state.latest_block_header.slot + # Verify that proposer index is the correct index + assert block.proposer_index == get_beacon_proposer_index(state) + # Verify that the parent matches + assert block.parent_root == hash_tree_root(state.latest_block_header) + # Cache current block as the new latest block + state.latest_block_header = BeaconBlockHeader( + slot=block.slot, + proposer_index=block.proposer_index, + parent_root=block.parent_root, + state_root=Bytes32(), # Overwritten in the next process_slot call + body_root=hash_tree_root(block.body), + ) + + # Verify proposer is not slashed + proposer = state.validators[block.proposer_index] + assert proposer.slashed == uint8(0) +``` + +#### Modified `process_operations` + +**Note:** `process_operations` is modified to process PTC attestations + +```python +def process_operations(state: BeaconState, body: BeaconBlockBody) -> None: + # Verify that outstanding deposits are processed up to the maximum number of deposits + assert len(body.deposits) == min(MAX_DEPOSITS, state.eth1_data.deposit_count - state.eth1_deposit_index) + + def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None: + for operation in operations: + fn(state, operation) + + for_ops(body.proposer_slashings, process_proposer_slashing) # [Modified in ePBS] + for_ops(body.attester_slashings, process_attester_slashing) # [Modified in ePBS] + for_ops(body.attestations, process_attestation) + for_ops(body.deposits, process_deposit) + for_ops(body.voluntary_exits, process_voluntary_exit) + for_ops(body.bls_to_execution_changes, process_bls_to_execution_change) + for_ops(body.payload_attestations, process_payload_attestation) # [New in ePBS] + for_ops(body.execution_payload_withdraw_requests, process_execution_layer_withdraw_request) # [New in ePBS] +``` + +##### Modified Proposer slashings + +```python +def process_proposer_slashing(state: BeaconState, proposer_slashing: ProposerSlashing) -> None: + header_1 = proposer_slashing.signed_header_1.message + header_2 = proposer_slashing.signed_header_2.message + + # Verify header slots match + assert header_1.slot == header_2.slot + # Verify header proposer indices match + assert header_1.proposer_index == header_2.proposer_index + # Verify the headers are different + assert header_1 != header_2 + # Verify the proposer is slashable + proposer = state.validators[header_1.proposer_index] + assert proposer.activation_epoch <= get_current_epoch(state) and not is_slashed_proposer(proposer) + # Verify signatures + for signed_header in (proposer_slashing.signed_header_1, proposer_slashing.signed_header_2): + domain = get_domain(state, DOMAIN_BEACON_PROPOSER, compute_epoch_at_slot(signed_header.message.slot)) + signing_root = compute_signing_root(signed_header.message, domain) + assert bls.Verify(proposer.pubkey, signing_root, signed_header.signature) + + # Apply penalty + penalty = PROPOSER_EQUIVOCATION_PENALTY_FACTOR * EFFECTIVE_BALANCE_INCREMENT + decrease_balance(state, header_1.proposer_index, penalty) + initiate_validator_exit(state, header_1.proposer_index) + proposer.slashed = add_flag(proposer.slashed, SLASHED_PROPOSER_FLAG_INDEX) + + # Apply proposer and whistleblower rewards + proposer_reward = Gwei((penalty // WHISTLEBLOWER_REWARD_QUOTIENT) * PROPOSER_WEIGHT // WEIGHT_DENOMINATOR) + increase_balance(state, get_beacon_proposer_index(state), proposer_reward) +``` + +##### Modified Attester slashings +**Note:** The only modification is the use of `is_attester_slashable_validator` + +```python +def process_attester_slashing(state: BeaconState, attester_slashing: AttesterSlashing) -> None: + attestation_1 = attester_slashing.attestation_1 + attestation_2 = attester_slashing.attestation_2 + assert is_slashable_attestation_data(attestation_1.data, attestation_2.data) + assert is_valid_indexed_attestation(state, attestation_1) + assert is_valid_indexed_attestation(state, attestation_2) + + slashed_any = False + indices = set(attestation_1.attesting_indices).intersection(attestation_2.attesting_indices) + for index in sorted(indices): + if is_attester_slashable_validator(state.validators[index], get_current_epoch(state)): + slash_validator(state, index) + slashed_any = True + assert slashed_any +``` + + +##### Modified `process_attestation` + +*Note*: The function `process_attestation` is modified to ignore attestations from the ptc + +```python +def process_attestation(state: BeaconState, attestation: Attestation) -> None: + data = attestation.data + assert data.target.epoch in (get_previous_epoch(state), get_current_epoch(state)) + assert data.target.epoch == compute_epoch_at_slot(data.slot) + assert data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot <= data.slot + SLOTS_PER_EPOCH + assert data.index < get_committee_count_per_slot(state, data.target.epoch) + + committee = get_beacon_committee(state, data.slot, data.index) + assert len(attestation.aggregation_bits) == len(committee) + + # Participation flag indices + participation_flag_indices = get_attestation_participation_flag_indices(state, data, state.slot - data.slot) + + # Verify signature + assert is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation)) + + # Update epoch participation flags + if data.target.epoch == get_current_epoch(state): + epoch_participation = state.current_epoch_participation + else: + epoch_participation = state.previous_epoch_participation + + ptc = get_ptc(state, data.slot) + attesting_indices = [i for i in get_attesting_indices(state, data, attestation.aggregation_bits) if i not in ptc] + proposer_reward_numerator = 0 + for index in attesting_indices + for flag_index, weight in enumerate(PARTICIPATION_FLAG_WEIGHTS): + if flag_index in participation_flag_indices and not has_flag(epoch_participation[index], flag_index): + epoch_participation[index] = add_flag(epoch_participation[index], flag_index) + proposer_reward_numerator += get_base_reward(state, index) * weight + + # Reward proposer + proposer_reward_denominator = (WEIGHT_DENOMINATOR - PROPOSER_WEIGHT) * WEIGHT_DENOMINATOR // PROPOSER_WEIGHT + proposer_reward = Gwei(proposer_reward_numerator // proposer_reward_denominator) + increase_balance(state, get_beacon_proposer_index(state), proposer_reward) +``` + +##### Modified `get_validator_from_deposit` +**Note:** The function `get_validator_from_deposit` is modified to take only a pubkey and withdrawal credentials and sets the effective balance to zero + +```python +def get_validator_from_deposit(pubkey: BLSPubkey, withdrawal_credentials: Bytes32) -> Validator: + return Validator( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + activation_eligibility_epoch=FAR_FUTURE_EPOCH, + activation_epoch=FAR_FUTURE_EPOCH, + exit_epoch=FAR_FUTURE_EPOCH, + withdrawable_epoch=FAR_FUTURE_EPOCH, + effective_balance=0, + ) +``` + +##### Modified `apply_deposit` + +```python +def apply_deposit(state: BeaconState, + pubkey: BLSPubkey, + withdrawal_credentials: Bytes32, + amount: uint64, + signature: BLSSignature) -> None: + validator_pubkeys = [v.pubkey for v in state.validators] + if pubkey not in validator_pubkeys: + # Verify the deposit signature (proof of possession) which is not checked by the deposit contract + deposit_message = DepositMessage( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + amount=amount, + ) + domain = compute_domain(DOMAIN_DEPOSIT) # Fork-agnostic domain since deposits are valid across forks + signing_root = compute_signing_root(deposit_message, domain) + if bls.Verify(pubkey, signing_root, signature): + index = len(state.validators) + state.validators.append(get_validator_from_deposit(pubkey, withdrawal_credentials)) + state.balances.append(0) + state.previous_epoch_participation.append(ParticipationFlags(0b0000_0000)) + state.current_epoch_participation.append(ParticipationFlags(0b0000_0000)) + state.inactivity_scores.append(uint64(0)) + else: + index = ValidatorIndex(validator_pubkeys.index(pubkey)) + state.pending_balance_deposits.append(PendingBalanceDeposit(index=index, amount=amount)) +``` + +##### Payload Attestations + +```python +def process_payload_attestation(state: BeaconState, payload_attestation: PayloadAttestation) -> None: + ## Check that the attestation is for the parent beacon block + data = payload_attestation.data + assert data.beacon_block_root == state.latest_block_header.parent_root + ## Check that the attestation is for the previous slot + assert data.slot + 1 == state.slot + + #Verify signature + indexed_payload_attestation = get_indexed_payload_attestation(state, data.slot, payload_attestation) + assert is_valid_indexed_payload_attestation(state, indexed_payload_attestation) + + ptc = get_ptc(state, data.slot) + if state.slot % SLOTS_PER_EPOCH == 0: + epoch_participation = state.previous_epoch_participation + else: + epoch_participation = state.current_epoch_participation + + # Return early if the attestation is for the wrong payload status + latest_payload_timestamp = state.latest_execution_payload_header.timestamp + present_timestamp = compute_timestamp_at_slot(state, data.slot) + payload_was_present = latest_payload_timestamp == present_timestamp + if data.payload_present != payload_was_present: + return + # Reward the proposer and set all the participation flags + proposer_reward_numerator = 0 + for index in indexed_payload_attestation.attesting_indices: + for flag_index, weight in enumerate(PARTICIPATION_FLAG_WEIGHTS): + if not has_flag(epoch_participation[index], flag_index): + epoch_participation[index] = add_flag(epoch_participation[index], flag_index) + proposer_reward_numerator += get_base_reward(state, index) * weight + + # Reward proposer + proposer_reward_denominator = (WEIGHT_DENOMINATOR - PROPOSER_WEIGHT) * WEIGHT_DENOMINATOR // PROPOSER_WEIGHT + proposer_reward = Gwei(proposer_reward_numerator // proposer_reward_denominator) + increase_balance(state, get_beacon_proposer_index(state), proposer_reward) +``` + +##### Execution Layer Withdraw Requests + +```python +def process_execution_layer_withdraw_request( + state: BeaconState, + execution_layer_withdraw_request: ExecutionLayerWithdrawRequest + ) -> None: + validator_pubkeys = [v.pubkey for v in state.validators] + validator_index = ValidatorIndex(validator_pubkeys.index(execution_layer_withdraw_request.validator_pubkey)) + validator = state.validators[validator_index] + + # Same conditions as in EIP7002 https://github.com/ethereum/consensus-specs/pull/3349/files#diff-7a6e2ba480d22d8bd035bd88ca91358456caf9d7c2d48a74e1e900fe63d5c4f8R223 + # Verify withdrawal credentials + assert validator.withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX + assert validator.withdrawal_credentials[12:] == execution_layer_withdraw_request.source_address + assert is_active_validator(validator, get_current_epoch(state)) + # Verify exit has not been initiated, and slashed + assert validator.exit_epoch == FAR_FUTURE_EPOCH: + # Verify the validator has been active long enough + assert get_current_epoch(state) >= validator.activation_epoch + config.SHARD_COMMITTEE_PERIOD + + pending_balance_to_withdraw = sum(item.amount for item in state.pending_partial_withdrawals if item.index == validator_index) + + available_balance = state.balances[validator_index] - MIN_ACTIVATION_BALANCE - pending_balance_to_withdraw + assert available_balance >= execution_layer_withdraw_request.balance + + exit_queue_epoch = compute_exit_epoch_and_update_churn(state, available_balance) + withdrawable_epoch = Epoch(exit_queue_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) + + state.pending_partial_withdrawals.append(PartialWithdrawal( + index=validator_index, + amount=available_balance, + withdrawable_epoch=withdrawable_epoch, + )) +``` + +#### Modified `process_withdrawals` +**Note:** TODO: This is modified to take only the State as parameter as they are deterministic. + +```python +def process_withdrawals(state: BeaconState) -> None: + ## return early if the parent block was empty + state.signed_execution_payload_header_envelope.message.header != state.latest_execution_payload_header: + return + withdrawals = get_expected_withdrawals(state) + state.last_withdrawals_root = hash_tree_root(withdrawals) + for withdrawal in withdrawals: + decrease_balance(state, withdrawal.validator_index, withdrawal.amount) + + # Update the next withdrawal index if this block contained withdrawals + if len(withdrawals) != 0: + latest_withdrawal = withdrawals[-1] + state.next_withdrawal_index = WithdrawalIndex(latest_withdrawal.index + 1) + + # Update the next validator index to start the next withdrawal sweep + if len(withdrawals) == MAX_WITHDRAWALS_PER_PAYLOAD: + # Next sweep starts after the latest withdrawal's validator index + next_validator_index = ValidatorIndex((withdrawals[-1].validator_index + 1) % len(state.validators)) + state.next_withdrawal_validator_index = next_validator_index + else: + # Advance sweep by the max length of the sweep if there was not a full set of withdrawals + next_index = state.next_withdrawal_validator_index + MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP + next_validator_index = ValidatorIndex(next_index % len(state.validators)) + state.next_withdrawal_validator_index = next_validator_index +``` + +#### New `verify_execution_payload_header_envelope_signature` + +```python +def verify_execution_payload_header_envelope_signature(state: BeaconState, + signed_header_envelope: SignedExecutionPayloadHeaderEnvelope) -> bool: + # Check the signature + builder = state.validators[signed_header_envelope.message.builder_index] + signing_root = compute_signing_root(signed_header_envelope.message, get_domain(state, DOMAIN_BEACON_BUILDER)) + return bls.Verify(builder.pubkey, signing_root, signed_header_envelope.signature) +``` + +#### New `process_execution_payload_header` + +```python +def process_execution_payload_header(state: BeaconState, block: BeaconBlock) -> None: + signed_header_envelope = block.body.signed_execution_payload_header_envelope + assert verify_execution_payload_header_envelope_signature(state, signed_header_envelope) + # Check that the builder has funds to cover the bid and schedule the funds for transfer + envelope = signed_header_envelope.message + builder_index = envelope.builder_index + amount = envelope.value + assert state.balances[builder_index] >= amount: + decrease_balance(state, builder_index, amount) + state.pending_balance_deposits.append(PendingBalanceDeposit(index=block.proposer_index, amount=amount)) + # Verify the withdrawals_root against the state cached ones + assert header.withdrawals_root == state.last_withdrawals_root + # Verify consistency of the parent hash with respect to the previous execution payload header + assert header.parent_hash == state.latest_execution_payload_header.block_hash + # Verify prev_randao + assert header.prev_randao == get_randao_mix(state, get_current_epoch(state)) + # Verify timestamp + assert header.timestamp == compute_timestamp_at_slot(state, state.slot) + # Cache the inclusion list proposer if the parent block was full + if is_parent_block_full(state): + state.latest_inclusion_list_proposer = block.proposer_index + # Cache execution payload header envelope + state.signed_execution_payload_header_envelope = signed_header_envelope +``` + +#### New `verify_execution_payload_signature` + +```python +def verify_execution_envelope_signature(state: BeaconState, signed_envelope: SignedExecutionPayloadEnvelope) -> bool: + builder = state.validators[signed_envelope.message.builder_index] + signing_root = compute_signing_root(signed_envelope.message, get_domain(state, DOMAIN_BEACON_BUILDER)) + return bls.Verify(builder.pubkey, signing_root, signed_envelope.signature) +``` + +#### New `verify_inclusion_list_summary_signature` + +```python +def verify_inclusion_list_summary_signature(state: BeaconState, signed_summary: SignedInclusionListSummary) -> bool: + # TODO: do we need a new domain? + summary = signed_summary.message + signing_root = compute_signing_root(summary, get_domain(state, DOMAIN_BEACON_PROPOSER)) + proposer = state.validators[message.proposer_index] + return bls.Verify(proposer.pubkey, signing_root, signed_summary.signature) +``` + +#### Modified `process_execution_payload` +*Note*: `process_execution_payload` is now an independent check in state transition. It is called when importing a signed execution payload proposed by the builder of the current slot. + +```python +def process_execution_payload(state: BeaconState, signed_envelope: SignedExecutionPayloadEnvelope, execution_engine: ExecutionEngine) -> None: + # Verify signature [New in ePBS] + assert verify_execution_envelope_signature(state, signed_envelope) + envelope = signed_envelope.message + payload = envelope.payload + # Verify inclusion list proposer + proposer_index = envelope.inclusion_list_proposer_index + assert proposer_index == state.previous_inclusion_list_proposer + # Verify inclusion list summary signature + signed_summary = SignedInclusionListSummary( + message=InclusionListSummary( + proposer_index=proposer_index + summary=payload.inclusion_list_summary) + signature=envelope.inclusion_list_signature) + assert verify_inclusion_list_summary_signature(state, signed_summary) + # Verify consistency with the beacon block + assert envelope.beacon_block_root == hash_tree_root(state.latest_block_header) + # Verify consistency with the committed header + hash = hash_tree_root(payload) + committed_envelope = state.signed_execution_payload_header_envelope.message + previous_hash = hash_tree_root(committed_envelope.payload) + assert hash == previous_hash + # Verify consistency with the envelope + assert envelope.builder_index == committed_envelope.builder_index + # Verify the execution payload is valid + versioned_hashes = [kzg_commitment_to_versioned_hash(commitment) for commitment in envelope.blob_kzg_commitments] + assert execution_engine.verify_and_notify_new_payload( + NewPayloadRequest( + execution_payload=payload, + versioned_hashes=versioned_hashes, + parent_beacon_block_root=state.latest_block_header.parent_root, + ) + ) + # Cache the execution payload header and proposer + state.latest_execution_payload_header = committed_envelope.header + state.previous_inclusion_list_proposer = state.latest_inclusion_list_proposer + # Verify the state root + assert envelope.state_root == hash_tree_root(state) +``` diff --git a/specs/_features/epbs/design.md b/specs/_features/epbs/design.md new file mode 100644 index 0000000000..4609400011 --- /dev/null +++ b/specs/_features/epbs/design.md @@ -0,0 +1,204 @@ + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [ePBS design notes](#epbs-design-notes) + - [Inclusion lists](#inclusion-lists) + - [Liveness](#liveness) + - [Censoring](#censoring) + - [Builders](#builders) + - [Builder Payments](#builder-payments) + - [Withdrawals](#withdrawals) + - [Blobs](#blobs) + - [PTC Rewards](#ptc-rewards) + - [PTC Attestations](#ptc-attestations) + - [Forkchoice changes](#forkchoice-changes) + - [Checkpoint states](#checkpoint-states) + - [Block slot](#block-slot) + - [Equivocations](#equivocations) + - [Increased Max EB](#increased-max-eb) + - [Validator transfers](#validator-transfers) + + + +# ePBS design notes + +## Inclusion lists + +ePBS introduces forward inclusion lists for proposers to guarantee censor resistanship of the network. We follow the design described in [this post](https://ethresear.ch/t/no-free-lunch-a-new-inclusion-list-design/16389). + +- Proposer for slot N submits a signed block and in parallel broadcasts pairs of `summaries` and `transactions` to be included at the beginning of slot N+1. `transactions` are just list of transactions that this proposer wants included at the most at the beginning of N+1. `Summaries` are lists consisting on addresses sending those transactions and their gas limits. The summaries are signed, the transactions aren't. An honest proposer is allowed to send many of these pairs that aren't committed to its beacon block so no double proposing slashing is involved. +- Validators for slot N will consider the block for validation only if they have seen at least one pair (summary, transactions). They will consider the block invalid if those transactions are not executable at the start of slot N and if they don't have at least 12.5% higher `maxFeePerGas` than the current slot's `maxFeePerGas`. +- The builder for slot N reveals its payload together with a signed summary of the proposer of slot N-1. Along the summary, the builder includes a list of transactions indices (in strictly increasing order) of the previous payload of slot N-1, that satisfy some entry in the signed inclusion list summary. The payload is considered only valid if the following applies + - For each index `i` in the payload's `inclusion_list_exclusions`, check that the ith transaction `tx[i]` of the payload for `N-1` satisfies some transaction `T[i]` of the current inclusion list. Here `T[i]` is the first entry in the payload's inclusion list that is satisfied by `tx[i]`. This `T[i]` is removed from the inclusion list summary. + - The remaining transactions in the inclusion list summary, are all satisfied by the first transactions in the current payload, in increasing order, starting from the first transaction. + - The payload is executable, that is, it's valid from the execution layer perspective. + +**Note:** in the event that the payload for the canonical block in slot N is not revealed, then the summaries and transactions list for slot N-1 remains valid, the honest proposer for slot N+1 is not allowed to submit a new IL and any such message will be ignored. The builder for N+1 still has to satisfy the summary of N-1. If there are k slots in a row that are missing payloads, the next full slot will still need to satisfy the inclusion list for N-1. + +### Liveness + +In the usual case of LMD+Ghost we have a proof of the *plausible liveness* theorem, that is that supermajority links can always be added to produce new finalized checkpoints provided there exist children extending the finalized chain. Here we prove that the next builder can always produce a valid payload, in particular, a payload that can satisfy the pending inclusion list. + +Let N be the last slot which contained a full execution payload, and let $N+1,..., N+k$, $k \geq 1$ be slots in the canonical chain, descending from $N$ that were either skipped or are *empty*, that is, the corresponding execution payload has not been revealed or hasn't been included. The consensus block for $N+k$ has been proposed and it is the canonical head. The builder for $N+k$ has to fulfill the inclusion list proposed by $N$. When importing the block $N$, validators have attested for availability of at least one valid inclusion list. That is, those transactions would be executable on top of the head block at the time. Let $P$ be the execution payload included by the builder of $N$, this is the current head payload. Transactions in the attested inclusion list can *only* be invalid in a child of $P$ if there are transactions in $P$ that have the same source address and gas usage that was below the gas limit in the summary. For any such transaction the builder can add such transaction to the exclusion list and not include it in its payload. If there are remaining transactions in its payload from the same address, the nonce will have to be higher nonce than the transaction that was added in the exclusion list. This process can be repeated until there are no more transactions in the summary from that given address that have been invalidated. + +### Censoring + +We prove the following: the builder cannot force a transaction in the inclusion list to revert due to gas limit usage. A malicious builder that attempts to add in the exclusion list some transactions from an address with high gas limit but low usage, so that the remaining transactions in the summary have lower gas limit and the included transaction with higher gas usage reverts. However, this is impossible since any transaction in the exclusion list has to have lower nonce since it was already included in the previous block. That is, any attempt by the builder of changing the order in which to include transactions in the exclusion list, will result in its payload being invalid, and thus the inclusion lists transactions that haven't been invalidated on N will remain valid for the next block. + +## Builders + +There is a new entity `Builder` that is a glorified validator (they are simply validators with a different withdrawal prefix `0x0b`) required to have a higher stake and required to sign when producing execution payloads. + +- Builders are also validators (otherwise their staked capital depreciates). +- There is nothing to be done to onboard builders as we can simply accept validators with the right `0x0b` withdrawal prefix before the fork. They will be builders automatically. We could however onboard builders by simply turning validators into builders if they achieve the necessary minimum balance and change their withdrawal prefix to be distinguished from normal validators at the fork. +- We need to include several changes from the [MaxEB PR](https://github.com/michaelneuder/consensus-specs/pull/3) in order to account with builders having an increased balance that would otherwise depreciate. + +## Builder Payments + +Payments are processed unconditionally when processing the signed execution payload header. There are cases to study for possible same-slot unbundling even by an equivocation. Same slot unbundling can happen if the proposer equivocates, and propagates his equivocation after seeing the reveal of the builder which happens at 8 seconds. The next proposer has to build on full which can only happen by being dishonest. Honest validators will vote for the previous block not letting the attack succeed. The honest builder does not lose his bid as the block is reorged. + +## Withdrawals + +Withdrawals are deterministic on the beacon state, so on a consensus layer block processing, they are immediately processed, then later when the payload appears we verify that the withdrawals in the payload agree with the already fulfilled withdrawals in the CL. + +So when importing the CL block for slot N, we process the expected withdrawals at that slot. We save the list of paid withdrawals to the beacon state. When the payload for slot N appears, we check that the withdrawals correspond to the saved withdrawals. If the payload does not appear, the saved withdrawals remain, so any future payload has to include those. + +## Blobs + +- KZG Commitments are now sent on the Execution Payload envelope broadcasted by the EL and the EL block can only be valid if the data is available. +- Blobs themselves may be broadcasted by the builder below as soon as it sees the beacon block if he sees it's safe. + +## PTC Rewards +- PTC members are obtained as the first members from each beacon slot committee that are not builders. +- attesters are rewarded as a full attestation when they get the right payload presence: that is, if they vote for full (resp. empty) and the payload is included (resp. not included) then they get their participation bits (target, source and head timely) set. Otherwise they get a penalty as a missed attestation. +- Attestations to the CL block from these members are just ignored. +- The proposer for slot N+1 must include PTC attestations for slot N. There is no rewards (and therefore no incentive) for the proposer to include attestations that voted incorrectly, perhaps we can simply accept 1 PTC attestation per block instead of the current two. + +## PTC Attestations + +There are two ways to import PTC attestations. CL blocks contain aggregates, called `PayloadAttestation` in the spec. And committee members broadcast unaggregated `PayloadAttestationMessage`s. The latter are only imported over the wire for the current slot, and the former are only imported on blocks for the previous slot. + +## Forkchoice changes + +There are significant design decisions to make due to the fact that a slot can have 3 different statuses: + +1. The CL block is not included (therefore no payload can be included). This is a *skipped* slot. +2. The CL block is included and the payload is not revealed. This is an *empty* slot. +3. The CL block and the payload are both included. This is a *full* slot. + +Consider the following fork +```mermaid +graph RL +A[N-1, Full] +B[N, Empty] --> A +C[N, Full] --> A +D[N+1, Full] --> B +``` + +In this fork the proposer of `N+1` is attempting to reorg the payload of `N` that was seen by the majority of the PTC. Suppose that honest validators see that the PTC has voted `N` to be full. Then because of proposer boost, the CL block of `N+1` will have 40% of the committee to start with. Assuming perfect participation, honest validators should see a weight of `100` for `(N, Full)` and a weight of `40` for `N+1` (notice that they attest before seeing the payload). They should choose to vote for `(N, Full)` instead of `N+1`. The question is how do we account for all of this? A few initial comments are in line +- CL attestation do not mention full or empty they simply have a beacon block root. Honest validators will have already set their PTC vote during `N` that `N` was full. +- The only changes to the view of `N` as empty/full could come only when importing `N+1`, a beacon block that contains PTC Messages attesting for the payload of `N`. However, if honest validators have already achieved the threshold for `full`, they will consider the block full. +- This is one design decision: instead of having a hard threshold on the PTC (50% in the current spec) we could have a relative one, say for example a simple majority of the counted votes. This has some minor engineering problems (like keeping track of who voted in forkchoice more than simply if they voted for present or not), but this can easily be changed if there are some liveness concerns. +- The honest builder for `N+1` would not submit a bid here, since honest builders would have seen `N` as full also, they would only build on top of the blockhash included in `N`. +- The honest PTC members for `N+1` will vote for `N, Full` they will be rewarded but they will not change the forkchoice view that `N` was already full. +- PTC members voting for a previous blockroot cannot change the forkchoice view of the payload status either way. + +So the questions is what changes do we need to make to our current weight accounting so that we have `(N, Full)` and `(N+1, Full)` as viable for head above, but not `(N, Empty)`?. Moreover, we want `(N, Full)` to be the winner in the above situation. Before dwelling in the justification, let me say right away that a proposer for `N+2` would call `get_head` and would get `N.root`. And then he will call `is_payload_present(N.root)` and he will get `True` so he will propose based on `(N, Full)` reorging back the dishonest (malinformed) proposer of `N+1`. The implementation of `is_payload_present` is trivial so the only question is how to do LMD counting so that `N` beats `N+1` in the head computation. + +There are several notions that can be changed when we have *empty* or *full* slots. +- Checkpoints: + - we can consider checkpoints to be of the form `(Epoch, Root, bool)` where the `bool` is to indicate if the Beacon block was full or not. + - Another option is to consider checkpoints to be of the form `(Epoch, Root)` exactly as we do today, but only consider the last *full* block before or equal to the `Epoch` start. +Both have advantages and disadvantages, the second one allows for different contending states to be the first state of an epoch, but it keeps all implementations exactly as they are today. + - A third approach, which seems the best so far, is to keep `(Epoch, Root)` and let head of the chain determine if it is *Full* or *Empty* as described below. +- Ancestry computations, as in `get_ancestor`. + - We can change the signature of this function to be of the form `get_ancestor(Store, Root, slot) -> (Root, bool)` So that it returns the beacon block root and weather or not it is based on *Full* or *Empty*. + - Otherwise we can simply return the last *Full* block in the line of ancestry. Again there are advantages and disadvantages. In this last case, it would be very hard to know if two given blocks with a given payload status are in the same chain or not. + + +The proposal I am considering at this moment is the following: +- Keep checkpoints as `(Epoch, Root) ` and allow different start of epoch blocks. +- An honest validator, when requesting the state at a given block root, will use its canonical state. That is computed as follows. In the example above, when requesting the state with block root `N`, if a call to `get_head` returned `N+1` then the validator would return the `store.block_states[N.root]` which corresponds to `N, Empty`. If instead returned `N` then it would return the state `store.execution_payload_states[N.root]` which corresponds to `N, Full`. +- Thus, when requesting the *justified state* for example, it will use the state that actually corresponds to its canonical chain and he needs to track only `Epoch, Root` for checkpoints, with minimal code changes. +- For LMD accounting, the proposal is to keep weights exactly as today with one exception: direct votes for `N` are *only* counted in the chains supporting `N, Full` or `N, Empty` according to the PTC vote. So, in the fork above, any honest validator that voted for `N` during slot `N` will be counted in the chain for `N, Full`, but not in the chain of `N+1, Full`. Honest validators during `N+1` will also vote for `N`, and also counting their votes in for `N, Full` and not the attacker's `N+1`. Suppose the chain advances with two more bad blocks as follows +```mermaid +graph RL +A[N-1, Full] +B[N, Empty] --> A +C[N, Full] --> A +D[N+1, Full] --> B +G[N+1, Empty] --> B +E[N+2, Full] --> D +F[N+3, Full] --> G +F ~~~ E +``` +In this case all the attesters for `N+1` will be counted depending on the PTC members that voted for `(N+1, Full)`. Assuming honest PTC members, they would have voted for `N` during `N+1` so any CL attesters for `N+1` would be voting for `N+1, Empty` thus only counting for the head in `(N+3, Full)`. + +### Checkpoint states +There is no current change in `store.checkpoint_states[root]`. In principle the "checkpoint state" should correspond to either the checkpoint block being full or empty. However, payload status does not change any consensus value for the state at the given time, so it does not matter if we continue using `store.block_states` which corresponds to the "empty" case. + +### Block slot + +Honest validators that vote for a parent block when a block is late, are contributing for this parent block support and are explicitly attesting that the current block is not present. This is taken into account in the new computation of `get_head`. Consider the following situation +```mermaid +graph RL +A[N-1, Full] +B[N, Full] --> A +``` +The block `N` has arrived late and the whole committee sees `A` as head and vote for `N-1`. At the start of `N+1` a call to `get_head` will return `N-1` as head and thus if the proposer of `N+1` is honest it will base its block on `N-1`. Suppose however that the proposer bases his block on top of `N`. Then we see +```mermaid +graph RL +A[N-1, Full] +B[N, Full] --> A +C[N+1, Full] --> B +``` +This block was timely so it gets proposer Boost. The real DAG is +```mermaid +graph RL +A[N-1, Full] +B[N, Full] --> A +C[N+1, Full] --> B +D[N-1, Full] --> A +E[N-1, Full] --> D +``` +And honest validators should still see `N-1` as head. The reason being that at the attestation deadline on `N+1` validators have seen block `N+1` appear, this block is valid and has 40% of a committee vote because of proposer boost. However, the branch for `N-1` has the full committee from `N` that has voted for it, and thus honest validators vote for `N-1` as valid. + +## Equivocations + +There is no need to do anything about proposer equivocations. Builders should reveal their block anyway. + +- At the time of reveal, the builder already has counted attestations for the current CL blocks, even if there are or not equivocations. Any equivocation available at this time will not have transactions that can unbundle him since he hasn't revealed. +- If the original block to which the builder committed is included, then the builder doesn't lose anything, that was the original intent. So if the original block is the overwhelming winner at the time of reveal, the builder can simply reveal and be safe that if there are any equivocations anyway his block was included. +- If the builder reveals, he knows that he can never be unbundled unless the next committee has a majority of malicious validators: attestations will go for an empty block before a block that is revealed after 8 seconds. +- So since the builder cannot be unbundled, then he either doesn't pay if the block is not included, or pays and its included. +- The splitting grief, that is, the proposer's block has about 50% of the vote at 8 seconds, remains. + +A little care has to be taken in the case of colluding proposers for `N` and `N+1`. Consider the example of the [previous section](#block-slot). The malicious proposer of `N` sends an early block to the builder and an equivocation after it has seen the payload. No honest validators will have voted for this equivocation. Suppose $\beta$ is the malicious stake. We have $1 - \beta$ for that votes for the early `N` as head and $\beta$ that will vote for the lately revealed block. Assuming $\beta < 0.5$ we have that the PTC committee will declare the equivocation as empty. The malicious proposer of `N+1` proposes based on the equivocating block `N` including some unbundled transactions. Because of the PTC vote, even the $\beta$ attestations for the equivocating block `N` will not count for `N+1` since it builds on *full* instead of empty. The weight of `N+1` is only given by proposer boost. The weight of the early `N` will be $1 - \beta$ thus beating the malicious `N+1` if $\beta < 0.6$ and thus honest validators will vote for the early `N` that included the builders' payload. However, the early block itself may cause a split view, in this case some attesters may have voted for `N-1` as head! in this situation we would have a DAG like this (we are not considering irrelevant branches) +```mermaid +graph RL +A[N-1, Full] +B[N, Full] --> A +H[N, Full] --> B +F[N', Full] --> A +I[N', Empty] --> A +C[N+1, Full] --> F +D[N-1, Full] --> A +E[N-1, Full] --> D +``` + +When recursing from the children of `N-1` the weights for the three children are as follows (when computing after `N+1` has been revealed and before validators for `N+1` attested) +- (N, Full) has gotten some vote $\gamma \leq 1 - \beta$. +- (N', Full) has zero weight. This is an important point. Proposer boost does not apply to it because even though $N+1$ will get proposer boost, it is based on the wrong `PTC` vote, and thus it does not count towards this node's weight. +- (N', Empty) has $\beta$ maximum. +- (N-1, Full) has $1 - \beta - \gamma$. + +Thus, supposing $\gamma < \beta$ we have that $1 - \beta - \gamma > 1 - 2 \beta > \beta$ as long as $\beta < 1/3$. Thus we are protected from these kinds of attacks from attackers up to 33%. + +Note however that if we were to apply proposer boost to `(N', Full)` then we see that there's a split now between the three possible heads. `N'` has proposer boost giving it $0.4$ so if $\gamma = 0.39$ we get that with $1 - \beta - \gamma < 0.4$ whenever $\beta \geq 0.2$. Thus a 20% attacker that can also split the network, would be able to carry this attack with two consecutive blocks. + +## Increased Max EB +This PR includes the changes from [this PR](https://github.com/michaelneuder/consensus-specs/pull/3). In particular it includes execution layer triggerable withdrawals. + +## Validator transfers +One of the main problems of the current design is that a builder can transfer arbitrary amounts to proposers by simply paying a large bid. This is dangerous from a forkchoice perspective as it moves weights from one branch to another instantaneously, it may prevent a large penalty in case of slashing, etc. In order to partially mitigate this, we churn the transfer overloading the deposit system of Max EB, that is we append a `PendingBalanceDeposit` object to the beacon state. This churns the increase in the proposer's balance while it discounts immediately the balance of the builder. We may want to revisit this and add also an exit churn and even deal with equivocations on future iterations. diff --git a/specs/_features/epbs/fork-choice.md b/specs/_features/epbs/fork-choice.md new file mode 100644 index 0000000000..519613f5a7 --- /dev/null +++ b/specs/_features/epbs/fork-choice.md @@ -0,0 +1,447 @@ +# ePBS -- Fork Choice + +## Table of contents + + + + +- [Introduction](#introduction) +- [Constant](#constant) +- [Helpers](#helpers) + - [Modified `LatestMessage`](#modified-latestmessage) + - [Modified `update_latest_messages`](#modified-update_latest_messages) + - [Modified `Store`](#modified-store) + - [`verify_inclusion_list`](#verify_inclusion_list) + - [`is_inclusion_list_available`](#is_inclusion_list_available) + - [`notify_ptc_messages`](#notify_ptc_messages) + - [`is_payload_present`](#is_payload_present) + - [Modified `get_ancestor`](#modified-get_ancestor) + - [Modified `get_checkpoint_block`](#modified-get_checkpoint_block) + - [`is_supporting_vote`](#is_supporting_vote) + - [Modified `get_weight`](#modified-get_weight) + - [Modified `get_head`](#modified-get_head) + - [New `get_block_hash`](#new-get_block_hash) +- [Updated fork-choice handlers](#updated-fork-choice-handlers) + - [`on_block`](#on_block) +- [New fork-choice handlers](#new-fork-choice-handlers) + - [`on_execution_payload`](#on_execution_payload) + - [`on_payload_attestation_message`](#on_payload_attestation_message) + + + + +## Introduction + +This is the modification of the fork choice accompanying the ePBS upgrade. + +## Constant + +| Name | Value | +| -------------------- | ----------- | +| `PAYLOAD_TIMELY_THRESHOLD` | `PTC_SIZE/2` (=`uint64(256)`) | + +## Helpers + +### Modified `LatestMessage` +**Note:** The class is modified to keep track of the slot instead of the epoch + +```python +@dataclass(eq=True, frozen=True) +class LatestMessage(object): + slot: Slot + root: Root +``` + +### Modified `update_latest_messages` +**Note:** the function `update_latest_messages` is updated to use the attestation slot instead of target. Notice that this function is only called on validated attestations and validators cannot attest twice in the same epoch without equivocating. Notice also that target epoch number and slot number are validated on `validate_on_attestation`. + +```python +def update_latest_messages(store: Store, attesting_indices: Sequence[ValidatorIndex], attestation: Attestation) -> None: + slot = attestation.data.slot + beacon_block_root = attestation.data.beacon_block_root + non_equivocating_attesting_indices = [i for i in attesting_indices if i not in store.equivocating_indices] + for i in non_equivocating_attesting_indices: + if i not in store.latest_messages or slot > store.latest_messages[i].slot: + store.latest_messages[i] = LatestMessage(slot=slot, root=beacon_block_root) +``` + +### Modified `Store` +**Note:** `Store` is modified to track the intermediate states of "empty" consensus blocks, that is, those consensus blocks for which the corresponding execution payload has not been revealed or has not been included on chain. + +```python +@dataclass +class Store(object): + time: uint64 + genesis_time: uint64 + justified_checkpoint: Checkpoint + finalized_checkpoint: Checkpoint + unrealized_justified_checkpoint: Checkpoint + unrealized_finalized_checkpoint: Checkpoint + proposer_boost_root: Root + equivocating_indices: Set[ValidatorIndex] + blocks: Dict[Root, BeaconBlock] = field(default_factory=dict) + block_states: Dict[Root, BeaconState] = field(default_factory=dict) + checkpoint_states: Dict[Checkpoint, BeaconState] = field(default_factory=dict) + latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict) + unrealized_justifications: Dict[Root, Checkpoint] = field(default_factory=dict) + execution_payload_states: Dict[Root, BeaconState] = field(default_factory=dict) # [New in ePBS] + ptc_vote: Dict[Root, Vector[bool, PTC_SIZE]] = field(default_factory=dict) # [New in ePBS] +``` + +### `verify_inclusion_list` +*[New in ePBS]* + +```python +def verify_inclusion_list(state: BeaconState, block: BeaconBlock, inclusion_list: InclusionList, execution_engine: ExecutionEngine) -> bool: + """ + returns true if the inclusion list is valid. + """ + # Check that the inclusion list corresponds to the block proposer + signed_summary = inclusion_list.summary + proposer_index = signed_summary.message.proposer_index + assert block.proposer_index == proposer_index + + # Check that the signature is correct + assert verify_inclusion_list_summary_signature(state, signed_summary) + + # TODO: These checks will also be performed by the EL surely so we can probably remove them from here. + # Check the summary and transaction list lengths + summary = signed_summary.message.summary + assert len(summary) <= MAX_TRANSACTIONS_PER_INCLUSION_LIST + assert len(inclusion_list.transactions) == len(summary) + + # TODO: These checks will also be performed by the EL surely so we can probably remove them from here. + # Check that the total gas limit is bounded + total_gas_limit = sum( entry.gas_limit for entry in summary ) + assert total_gas_limit <= MAX_GAS_PER_INCLUSION_LIST + + # Check that the inclusion list is valid + return execution_engine.notify_new_inclusion_list(NewInclusionListRequest( + inclusion_list=inclusion_list.transactions, + summary=inclusion_list.summary.message.summary, + parent_block_hash = state.latest_execution_payload_header.block_hash)) +``` + +### `is_inclusion_list_available` +*[New in ePBS]* + +```python +def is_inclusion_list_available(state: BeaconState, block: BeaconBlock) -> bool: + """ + Returns whether one inclusion list for the corresponding block was seen in full and has been validated. + There is one exception if the parent consensus block did not contain an exceution payload, in which case + We return true early + + `retrieve_inclusion_list` is implementation and context dependent + It returns one inclusion list that was broadcasted during the given slot by the given proposer. + Note: the p2p network does not guarantee sidecar retrieval outside of + `MIN_SLOTS_FOR_INCLUSION_LISTS_REQUESTS` + """ + # Ignore the list if the parent consensus block did not contain a payload + if !is_parent_block_full(state): + return True + + # verify the inclusion list + inclusion_list = retrieve_inclusion_list(block.slot, block.proposer_index) + return verify_inclusion_list(state, block, inclusion_list, EXECUTION_ENGINE) +``` + +### `notify_ptc_messages` + +```python +def notify_ptc_messages(store: Store, state: BeaconState, payload_attestations: Sequence[PayloadAttestation]) -> None: + """ + Extracts a list of ``PayloadAttestationMessage`` from ``payload_attestations`` and updates the store with them + """ + for payload_attestation in payload_attestations: + indexed_payload_attestation = get_indexed_payload_attestation(state, state.slot - 1, payload_attestation) + for idx in indexed_payload_attestation.attesting_indices: + store.on_payload_attestation_message(PayloadAttestationMessage(validator_index=idx, + data=payload_attestation.data, signature= BLSSignature(), is_from_block=true) +``` + +### `is_payload_present` + +```python +def is_payload_present(store: Store, beacon_block_root: Root) -> bool: + """ + return whether the execution payload for the beacon block with root ``beacon_block_root`` was voted as present + by the PTC + """ + # The beacon block root must be known + assert beacon_block_root in store.ptc_vote + return store.ptc_vote[beacon_block_root].count(True) > PAYLOAD_TIMELY_THRESHOLD +``` + +### Modified `get_ancestor` +**Note:** `get_ancestor` is modified to return whether the chain is based on an *empty* or *full* block. + +```python +def get_ancestor(store: Store, root: Root, slot: Slot) -> tuple[Root, bool]: + """ + returns the beacon block root of the ancestor of the beacon block with ``root`` at``slot`` and it also + returns ``true`` if it based on a full block and ``false`` otherwise. + If the beacon block with ``root`` is already at ``slot`` it returns it's PTC status. + """ + block = store.blocks[root] + if block.slot == slot: + return [root, store.is_payload_present(root)] + parent = store.blocks[block.parent_root] + if parent.slot > slot: + return get_ancestor(store, block.parent_root, slot) + if block.body.signed_execution_payload_header_envelope.message.parent_hash == + parent.body.signed_execution_payload_header_envelope.message.block_hash: + return (block.parent_root, True) + return (block.parent_root, False) +``` + +### Modified `get_checkpoint_block` +**Note:** `get_checkpoint_block` is modified to use the new `get_ancestor` + +```python +def get_checkpoint_block(store: Store, root: Root, epoch: Epoch) -> Root: + """ + Compute the checkpoint block for epoch ``epoch`` in the chain of block ``root`` + """ + epoch_first_slot = compute_start_slot_at_epoch(epoch) + (ancestor_root,_) = get_ancestor(store, root, epoch_first_slot) + return ancestor_root +``` + + +### `is_supporting_vote` + +```python +def is_supporting_vote(store: Store, root: Root, slot: Slot, is_payload_present: bool, message: LatestMessage) -> bool: + """ + returns whether a vote for ``message.root`` supports the chain containing the beacon block ``root`` with the + payload contents indicated by ``is_payload_present`` as head during slot ``slot``. + """ + if root == message.root: + # an attestation for a given root always counts for that root regardless if full or empty + return slot <= message.slot + message_block = store.blocks[message.root] + if slot > message_block.slot: + return False + (ancestor_root, is_ancestor_full) = get_ancestor(store, message.root, slot) + return (root == ancestor_root) and (is_payload_preset == is_ancestor_full) +``` + +### Modified `get_weight` + +**Note:** `get_weight` is modified to only count votes for descending chains that support the status of a triple `Root, Slot, bool`, where the `bool` indicates if the block was full or not. + +```python +def get_weight(store: Store, root: Root, slot: Slot, is_payload_present: bool) -> Gwei: + state = store.checkpoint_states[store.justified_checkpoint] + unslashed_and_active_indices = [ + i for i in get_active_validator_indices(state, get_current_epoch(state)) + if not is_slashed_attester(state.validators[i]) + ] + attestation_score = Gwei(sum( + state.validators[i].effective_balance for i in unslashed_and_active_indices + if (i in store.latest_messages + and i not in store.equivocating_indices + and is_supporting_vote(store, root, slot, is_payload_present, store.latest_messages[i])) + )) + if store.proposer_boost_root == Root(): + # Return only attestation score if ``proposer_boost_root`` is not set + return attestation_score + + # Calculate proposer score if ``proposer_boost_root`` is set + proposer_score = Gwei(0) + # Boost is applied if ``root`` is an ancestor of ``proposer_boost_root`` + (ancestor_root, is_ancestor_full) = get_ancestor(store, store.proposer_boost_root, store.blocks[root].slot) + if (ancestor_root == root) and (is_ancestor_full == is_payload_present): + committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH + proposer_score = (committee_weight * PROPOSER_SCORE_BOOST) // 100 + return attestation_score + proposer_score +``` + +### Modified `get_head` + +**Note:** `get_head` is modified to use the new `get_weight` function. It returns the Beacon block root of the head block and whether its payload is considered present or not. + +```python +def get_head(store: Store) -> tuple[Root, bool]: + # Get filtered block tree that only includes viable branches + blocks = get_filtered_block_tree(store) + # Execute the LMD-GHOST fork choice + head_root = store.justified_checkpoint.root + head_block = store.blocks[head_root] + head_slot = head_block.slot + head_full = is_payload_present(store, head_root) + while True: + children = [ + (root, block.slot, present) for (root, block) in blocks.items() + if block.parent_root == head_root for present in (True, False) + ] + if len(children) == 0: + return (head_root, head_full) + # if we have children we consider the current head advanced as a possible head + children += [(head_root, head_slot + 1, head_full)] + # Sort by latest attesting balance with ties broken lexicographically + # Ties broken by favoring full blocks + # Ties broken then by favoring higher slot numbers + # Ties then broken by favoring block with lexicographically higher root + # TODO: Can (root, full), (root, empty) have the same weight? + child_root = max(children, key=lambda (root, slot, present): (get_weight(store, root, slot, present), present, slot, root)) + if child_root == head_root: + return (head_root, head_full) + head_root = child_root + head_full = is_payload_present(store, head_root) +``` + +### New `get_block_hash` + +```python +def get_blockhash(store: Store, root: Root) -> Hash32: + """ + returns the blockHash of the latest execution payload in the chain containing the + beacon block with root ``root`` + """ + # The block is known + if is_payload_present(store, root): + return hash(store.execution_payload_states[root].latest_block_header) + return hash(store.block__states[root].latest_block_header) +``` + +## Updated fork-choice handlers + +### `on_block` + +*Note*: The handler `on_block` is modified to consider the pre `state` of the given consensus beacon block depending not only on the parent block root, but also on the parent blockhash. There is also the addition of the inclusion list availability check. + +In addition we delay the checking of blob availability until the processing of the execution payload. + +```python +def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: + """ + Run ``on_block`` upon receiving a new block. + """ + block = signed_block.message + # Parent block must be known + assert block.parent_root in store.block_states + + # Check if this blocks builds on empty or full parent block + parent_block = store.blocks[block.parent_root] + parent_signed_payload_header_envelope = parent_block.body.signed_execution_payload_header_envelope + parent_payload_hash = parent_signed_payload_header_envelope.message.header.block_hash + current_signed_payload_header_envelope = block.body.signed_execution_payload_header_envelope + current_payload_parent_hash = current_signed_payload_header_envelope.message.header.parent_hash + # Make a copy of the state to avoid mutability issues + if current_payload_parent_hash == parent_payload_hash: + assert block.parent_root in store.execution_payload_states + state = copy(store.execution_payload_states[block.parent_root]) + else: + state = copy(store.block_states[block.parent_root]) + + # Blocks cannot be in the future. If they are, their consideration must be delayed until they are in the past. + current_slot = get_current_slot(store) + assert current_slot >= block.slot + + # Check that block is later than the finalized epoch slot (optimization to reduce calls to get_ancestor) + finalized_slot = compute_start_slot_at_epoch(store.finalized_checkpoint.epoch) + assert block.slot > finalized_slot + # Check block is a descendant of the finalized block at the checkpoint finalized slot + finalized_checkpoint_block = get_checkpoint_block( + store, + block.parent_root, + store.finalized_checkpoint.epoch, + ) + assert store.finalized_checkpoint.root == finalized_checkpoint_block + + # Check if there is a valid inclusion list. + # This check is performed only if the block's slot is within the visibility window + # If not, this block MAY be queued and subsequently considered when a valid inclusion list becomes available + if block.slot + MIN_SLOTS_FOR_INCLUSION_LISTS_REQUESTS >= current_slot: + assert is_inclusion_list_available(state, block) + + # Check the block is valid and compute the post-state + block_root = hash_tree_root(block) + state_transition(state, signed_block, True) + + # Add new block to the store + store.blocks[block_root] = block + # Add new state for this block to the store + store.block_states[block_root] = state + # Add a new PTC voting for this block to the store + store.ptc_vote[block_root] = [False]*PTC_SIZE + + # Notify the store about the payload_attestations in the block + store.notify_ptc_messages(state, block.body.payload_attestations) + + # Add proposer score boost if the block is timely + time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT + is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT + if get_current_slot(store) == block.slot and is_before_attesting_interval: + store.proposer_boost_root = hash_tree_root(block) + + # Update checkpoints in store if necessary + update_checkpoints(store, state.current_justified_checkpoint, state.finalized_checkpoint) + + # Eagerly compute unrealized justification and finality. + compute_pulled_up_tip(store, block_root) +``` + +## New fork-choice handlers + +### `on_execution_payload` + +```python +def on_excecution_payload(store: Store, signed_envelope: SignedExecutionPayloadEnvelope) -> None: + """ + Run ``on_execution_payload`` upon receiving a new execution payload. + """ + envelope = signed_envelope.message + # The corresponding beacon block root needs to be known + assert envelope.beacon_block_root in store.block_states + + # Check if blob data is available + # If not, this payload MAY be queued and subsequently considered when blob data becomes available + assert is_data_available(envelope.beacon_block_root, envelope.blob_kzg_commitments) + + # Make a copy of the state to avoid mutability issues + state = copy(store.block_states[envelope.beacon_block_root]) + + # Process the execution payload + process_execution_payload(state, signed_envelope, EXECUTION_ENGINE) + + #Add new state for this payload to the store + store.execution_payload_states[envelope.beacon_block_root] = state +``` + +### `on_payload_attestation_message` + +```python +def on_payload_attestation_message(store: Store, + ptc_message: PayloadAttestationMessage, is_from_block: bool=False) -> None: + """ + Run ``on_payload_attestation_message`` upon receiving a new ``ptc_message`` directly on the wire. + """ + # The beacon block root must be known + data = ptc_message.data + # PTC attestation must be for a known block. If block is unknown, delay consideration until the block is found + state = store.block_states[data.beacon_block_root] + ptc = get_ptc(state, data.slot) + # PTC votes can only change the vote for their assigned beacon block, return early otherwise + if data.slot != state.slot: + return + + # Verify the signature and check that its for the current slot if it is coming from the wire + if not is_from_block: + # Check that the attestation is for the current slot + assert data.slot == get_current_slot(store) + # Check that the attester is from the current ptc + assert ptc_message.validator_index in ptc + # Verify the signature + assert is_valid_indexed_payload_attestation(state, + IndexedPayloadAttestation(attesting_indices = [ptc_message.validator_index], data = data, + signature = ptc_message.signature)) + # Update the ptc vote for the block + # TODO: Do we want to slash ptc members that equivocate? + # we are updating here the message and so the last vote will be the one that counts. + ptc_index = ptc.index(ptc_message.validator_index) + ptc_vote = store.ptc_vote[data.beacon_block_root] + ptc_vote[ptc_index] = data.present +``` diff --git a/specs/_features/epbs/weak-subjectivity.md b/specs/_features/epbs/weak-subjectivity.md new file mode 100644 index 0000000000..19bd08068b --- /dev/null +++ b/specs/_features/epbs/weak-subjectivity.md @@ -0,0 +1,56 @@ +# ePBS -- Weak Subjectivity Guide + +## Table of contents + + + + + +- [Weak Subjectivity Period](#weak-subjectivity-period) + - [Calculating the Weak Subjectivity Period](#calculating-the-weak-subjectivity-period) + - [Modified `compute_weak_subjectivity_period`](#modified-compute_weak_subjectivity_period) + + + +## Weak Subjectivity Period + +### Calculating the Weak Subjectivity Period + +#### Modified `compute_weak_subjectivity_period` +**Note:** The function `compute_weak_subjectivity_period` is modified to use the modified churn in ePBS. + +```python +def compute_weak_subjectivity_period(state: BeaconState) -> uint64: + """ + Returns the weak subjectivity period for the current ``state``. + This computation takes into account the effect of: + - validator set churn (bounded by ``get_validator_churn_limit()`` per epoch), and + - validator balance top-ups (bounded by ``MAX_DEPOSITS * SLOTS_PER_EPOCH`` per epoch). + A detailed calculation can be found at: + https://github.com/runtimeverification/beacon-chain-verification/blob/master/weak-subjectivity/weak-subjectivity-analysis.pdf + """ + ws_period = MIN_VALIDATOR_WITHDRAWABILITY_DELAY + N = len(get_active_validator_indices(state, get_current_epoch(state))) + t = get_total_active_balance(state) // N // ETH_TO_GWEI + T = MAX_EFFECTIVE_BALANCE // ETH_TO_GWEI + delta = get_validator_churn_limit(state) // MIN_ACTIVATION_BALANCE + Delta = MAX_DEPOSITS * SLOTS_PER_EPOCH + D = SAFETY_DECAY + + if T * (200 + 3 * D) < t * (200 + 12 * D): + epochs_for_validator_set_churn = ( + N * (t * (200 + 12 * D) - T * (200 + 3 * D)) // (600 * delta * (2 * t + T)) + ) + epochs_for_balance_top_ups = ( + N * (200 + 3 * D) // (600 * Delta) + ) + ws_period += max(epochs_for_validator_set_churn, epochs_for_balance_top_ups) + else: + ws_period += ( + 3 * N * D * t // (200 * Delta * (T - t)) + ) + + return ws_period +``` + +