diff --git a/presets/mainnet/gloas.yaml b/presets/mainnet/gloas.yaml index 599a426d506..a748aae6d12 100644 --- a/presets/mainnet/gloas.yaml +++ b/presets/mainnet/gloas.yaml @@ -10,6 +10,13 @@ PTC_SIZE: 512 # 2**2 (= 4) attestations MAX_PAYLOAD_ATTESTATIONS: 4 +# Execution +# --------------------------------------------------------------- +# 2**8 (= 256) builder deposit requests +MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD: 256 +# 2**4 (= 16) builder exit requests +MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD: 16 + # State list lengths # --------------------------------------------------------------- # 2**40 (= 1,099,511,627,776) builder spots diff --git a/presets/minimal/gloas.yaml b/presets/minimal/gloas.yaml index 559c2d46df7..8b606894149 100644 --- a/presets/minimal/gloas.yaml +++ b/presets/minimal/gloas.yaml @@ -10,6 +10,13 @@ PTC_SIZE: 16 # 2**2 (= 4) attestations MAX_PAYLOAD_ATTESTATIONS: 4 +# Execution +# --------------------------------------------------------------- +# 2**8 (= 256) builder deposit requests +MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD: 256 +# 2**4 (= 16) builder exit requests +MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD: 16 + # State list lengths # --------------------------------------------------------------- # 2**40 (= 1,099,511,627,776) builder spots diff --git a/pysetup/spec_builders/heze.py b/pysetup/spec_builders/heze.py index f378571d7c4..79ddb341a3e 100644 --- a/pysetup/spec_builders/heze.py +++ b/pysetup/spec_builders/heze.py @@ -68,6 +68,8 @@ def is_inclusion_list_satisfied(self: ExecutionEngine, def deprecate_functions(cls) -> set[str]: return { "initialize_ptc_window", + "is_builder_withdrawal_credential", + "is_pending_validator", "onboard_builders_from_pending_deposits", "upgrade_to_gloas", } diff --git a/specs/_features/eip8025/beacon-chain.md b/specs/_features/eip8025/beacon-chain.md index d9222f76bfa..d9e2ae848b9 100644 --- a/specs/_features/eip8025/beacon-chain.md +++ b/specs/_features/eip8025/beacon-chain.md @@ -50,7 +50,7 @@ and imports proof types from [proof-engine.md](./proof-engine.md). | Name | Value | | ------------------------ | -------------------------- | -| `DOMAIN_EXECUTION_PROOF` | `DomainType('0x0E000000')` | +| `DOMAIN_EXECUTION_PROOF` | `DomainType('0x0F000000')` | ## Containers diff --git a/specs/_features/eip8148/beacon-chain.md b/specs/_features/eip8148/beacon-chain.md index bd9b37219bf..5ab4bbb5882 100644 --- a/specs/_features/eip8148/beacon-chain.md +++ b/specs/_features/eip8148/beacon-chain.md @@ -55,7 +55,7 @@ control their balance withdrawals more precisely. | Name | Value | | ------------------------------ | ---------------- | -| `SWEEP_THRESHOLD_REQUEST_TYPE` | `Bytes1('0x03')` | +| `SWEEP_THRESHOLD_REQUEST_TYPE` | `Bytes1('0x05')` | ### Sweep threshold validation @@ -136,6 +136,8 @@ class ExecutionRequests(Container): deposits: List[DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD] withdrawals: List[WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD] consolidations: List[ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD] + builder_deposits: List[BuilderDepositRequest, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD] + builder_exits: List[BuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD] # [New in EIP8148] sweep_thresholds: List[SetSweepThresholdRequest, MAX_SET_SWEEP_THRESHOLD_REQUESTS_PER_PAYLOAD] ``` @@ -238,6 +240,8 @@ def get_execution_requests_list(execution_requests: ExecutionRequests) -> Sequen (DEPOSIT_REQUEST_TYPE, execution_requests.deposits), (WITHDRAWAL_REQUEST_TYPE, execution_requests.withdrawals), (CONSOLIDATION_REQUEST_TYPE, execution_requests.consolidations), + (BUILDER_DEPOSIT_REQUEST_TYPE, execution_requests.builder_deposits), + (BUILDER_EXIT_REQUEST_TYPE, execution_requests.builder_exits), # [New in EIP8148] (SWEEP_THRESHOLD_REQUEST_TYPE, execution_requests.sweep_thresholds), ] @@ -375,6 +379,8 @@ def apply_parent_execution_payload( for_ops(requests.deposits, process_deposit_request) for_ops(requests.withdrawals, process_withdrawal_request) for_ops(requests.consolidations, process_consolidation_request) + for_ops(requests.builder_deposits, process_builder_deposit_request) + for_ops(requests.builder_exits, process_builder_exit_request) # [New in EIP8148] for_ops(requests.sweep_thresholds, process_set_sweep_threshold_request) diff --git a/specs/gloas/beacon-chain.md b/specs/gloas/beacon-chain.md index 98811aef830..6b3b40fc99d 100644 --- a/specs/gloas/beacon-chain.md +++ b/specs/gloas/beacon-chain.md @@ -11,9 +11,11 @@ - [Domains](#domains) - [Misc](#misc) - [Withdrawal prefixes](#withdrawal-prefixes) + - [Execution-layer triggered requests](#execution-layer-triggered-requests) - [Preset](#preset) - [Misc](#misc-1) - [Max operations per block](#max-operations-per-block) + - [Execution](#execution) - [State list lengths](#state-list-lengths) - [Withdrawals processing](#withdrawals-processing) - [Configuration](#configuration) @@ -24,6 +26,8 @@ - [`Builder`](#builder) - [`BuilderPendingPayment`](#builderpendingpayment) - [`BuilderPendingWithdrawal`](#builderpendingwithdrawal) + - [`BuilderDepositRequest`](#builderdepositrequest) + - [`BuilderExitRequest`](#builderexitrequest) - [`PayloadAttestationData`](#payloadattestationdata) - [`PayloadAttestation`](#payloadattestation) - [`PayloadAttestationMessage`](#payloadattestationmessage) @@ -36,6 +40,7 @@ - [`BeaconBlockBody`](#beaconblockbody) - [`BeaconState`](#beaconstate) - [`ExecutionPayload`](#executionpayload) + - [`ExecutionRequests`](#executionrequests) - [Dataclasses](#dataclasses) - [Modified dataclasses](#modified-dataclasses) - [`ExpectedWithdrawals`](#expectedwithdrawals) @@ -91,18 +96,19 @@ - [Modified `process_withdrawals`](#modified-process_withdrawals) - [Execution payload](#execution-payload) - [Removed `process_execution_payload`](#removed-process_execution_payload) + - [Modified `get_execution_requests_list`](#modified-get_execution_requests_list) - [Execution payload bid](#execution-payload-bid) - [New `verify_execution_payload_bid_signature`](#new-verify_execution_payload_bid_signature) - [New `process_execution_payload_bid`](#new-process_execution_payload_bid) - [Operations](#operations) - [Modified `process_operations`](#modified-process_operations) - - [Deposit requests](#deposit-requests) + - [Builder deposit requests](#builder-deposit-requests) + - [New `is_valid_builder_deposit_signature`](#new-is_valid_builder_deposit_signature) - [New `get_index_for_new_builder`](#new-get_index_for_new_builder) - [New `add_builder_to_registry`](#new-add_builder_to_registry) - - [New `apply_deposit_for_builder`](#new-apply_deposit_for_builder) - - [Modified `process_deposit_request`](#modified-process_deposit_request) - - [Voluntary exits](#voluntary-exits) - - [Modified `process_voluntary_exit`](#modified-process_voluntary_exit) + - [New `process_builder_deposit_request`](#new-process_builder_deposit_request) + - [Builder exit requests](#builder-exit-requests) + - [New `process_builder_exit_request`](#new-process_builder_exit_request) - [Attestations](#attestations) - [Modified `process_attestation`](#modified-process_attestation) - [Payload attestations](#payload-attestations) @@ -123,6 +129,8 @@ Gloas is a consensus-layer upgrade containing a number of features. Including: validators from proposing - [EIP-8061](https://eips.ethereum.org/EIPS/eip-8061): Increase exit and consolidation churn +- [EIP-8282](https://eips.ethereum.org/EIPS/eip-8282): Builder Execution + Requests ## Types @@ -146,6 +154,7 @@ Gloas is a consensus-layer upgrade containing a number of features. Including: | `DOMAIN_BEACON_BUILDER` | `DomainType('0x0B000000')` | | `DOMAIN_PTC_ATTESTER` | `DomainType('0x0C000000')` | | `DOMAIN_PROPOSER_PREFERENCES` | `DomainType('0x0D000000')` | +| `DOMAIN_BUILDER_DEPOSIT` | `DomainType('0x0E000000')` | ### Misc @@ -157,10 +166,21 @@ Gloas is a consensus-layer upgrade containing a number of features. Including: ### Withdrawal prefixes +*Note*: `BUILDER_WITHDRAWAL_PREFIX` is a temporary constant which is only used +to onboard builders at the fork. It will be deprecated after the upgrade and a +future validator withdrawal prefix may reuse this value. + | Name | Value | Description | | --------------------------- | ---------------- | ------------------------------------------ | | `BUILDER_WITHDRAWAL_PREFIX` | `Bytes1('0x03')` | Withdrawal credential prefix for a builder | +### Execution-layer triggered requests + +| Name | Value | +| ------------------------------ | ---------------- | +| `BUILDER_DEPOSIT_REQUEST_TYPE` | `Bytes1('0x03')` | +| `BUILDER_EXIT_REQUEST_TYPE` | `Bytes1('0x04')` | + ## Preset ### Misc @@ -175,6 +195,13 @@ Gloas is a consensus-layer upgrade containing a number of features. Including: | -------------------------- | ----- | | `MAX_PAYLOAD_ATTESTATIONS` | `4` | +### Execution + +| Name | Value | Description | +| ------------------------------------------ | ---------------------- | ---------------------------------------------------------- | +| `MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD` | `uint64(2**8)` (= 256) | Maximum number of builder deposit requests in each payload | +| `MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD` | `uint64(2**4)` (= 16) | Maximum number of builder exit requests in each payload | + ### State list lengths | Name | Value | Unit | @@ -238,6 +265,24 @@ class BuilderPendingWithdrawal(Container): builder_index: BuilderIndex ``` +#### `BuilderDepositRequest` + +```python +class BuilderDepositRequest(Container): + pubkey: BLSPubkey + withdrawal_credentials: Bytes32 + amount: Gwei + signature: BLSSignature +``` + +#### `BuilderExitRequest` + +```python +class BuilderExitRequest(Container): + source_address: ExecutionAddress + pubkey: BLSPubkey +``` + #### `PayloadAttestationData` ```python @@ -443,6 +488,19 @@ class ExecutionPayload(Container): slot_number: uint64 ``` +#### `ExecutionRequests` + +```python +class ExecutionRequests(Container): + deposits: List[DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD] + withdrawals: List[WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD] + consolidations: List[ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD] + # [New in Gloas:EIP8282] + builder_deposits: List[BuilderDepositRequest, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD] + # [New in Gloas:EIP8282] + builder_exits: List[BuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD] +``` + ## Dataclasses ### Modified dataclasses @@ -1147,6 +1205,10 @@ def apply_parent_execution_payload( for_ops(requests.deposits, process_deposit_request) for_ops(requests.withdrawals, process_withdrawal_request) for_ops(requests.consolidations, process_consolidation_request) + # [New in Gloas:EIP8282] + for_ops(requests.builder_deposits, process_builder_deposit_request) + # [New in Gloas:EIP8282] + for_ops(requests.builder_exits, process_builder_exit_request) # Settle the builder payment if parent_epoch == get_current_epoch(state): @@ -1423,6 +1485,27 @@ def process_withdrawals( `on_execution_payload_envelope`. Payload processing is deferred to the next beacon block via `process_parent_execution_payload`. +##### Modified `get_execution_requests_list` + +```python +def get_execution_requests_list(execution_requests: ExecutionRequests) -> Sequence[bytes]: + requests = [ + (DEPOSIT_REQUEST_TYPE, execution_requests.deposits), + (WITHDRAWAL_REQUEST_TYPE, execution_requests.withdrawals), + (CONSOLIDATION_REQUEST_TYPE, execution_requests.consolidations), + # [New in Gloas:EIP8282] + (BUILDER_DEPOSIT_REQUEST_TYPE, execution_requests.builder_deposits), + # [New in Gloas:EIP8282] + (BUILDER_EXIT_REQUEST_TYPE, execution_requests.builder_exits), + ] + + return [ + request_type + ssz_serialize(request_data) + for request_type, request_data in requests + if len(request_data) != 0 + ] +``` + #### Execution payload bid ##### New `verify_execution_payload_bid_signature` @@ -1514,7 +1597,6 @@ def process_operations(state: BeaconState, body: BeaconBlockBody) -> None: for_ops(body.attester_slashings, process_attester_slashing) # [Modified in Gloas:EIP7732] for_ops(body.attestations, process_attestation) - # [Modified in Gloas:EIP7732] for_ops(body.voluntary_exits, process_voluntary_exit) for_ops(body.bls_to_execution_changes, process_bls_to_execution_change) # [Modified in Gloas:EIP7732] @@ -1527,7 +1609,26 @@ def process_operations(state: BeaconState, body: BeaconBlockBody) -> None: for_ops(body.payload_attestations, process_payload_attestation) ``` -##### Deposit requests +##### Builder deposit requests + +###### New `is_valid_builder_deposit_signature` + +*Note*: Builder deposits are signed over the `DepositMessage` under +`DOMAIN_BUILDER_DEPOSIT`. The dedicated domain ensures that validator deposit +signatures and builder deposit signatures cannot be replayed against the other +deposit contract. + +```python +def is_valid_builder_deposit_signature(request: BuilderDepositRequest) -> bool: + deposit_message = DepositMessage( + pubkey=request.pubkey, + withdrawal_credentials=request.withdrawal_credentials, + amount=request.amount, + ) + domain = compute_domain(DOMAIN_BUILDER_DEPOSIT) + signing_root = compute_signing_root(deposit_message, domain) + return bls.Verify(request.pubkey, signing_root, request.signature) +``` ###### New `get_index_for_new_builder` @@ -1545,7 +1646,8 @@ def get_index_for_new_builder(state: BeaconState) -> BuilderIndex: def add_builder_to_registry( state: BeaconState, pubkey: BLSPubkey, - withdrawal_credentials: Bytes32, + version: uint8, + execution_address: ExecutionAddress, amount: uint64, slot: Slot, ) -> None: @@ -1554,8 +1656,8 @@ def add_builder_to_registry( get_index_for_new_builder(state), Builder( pubkey=pubkey, - version=uint8(withdrawal_credentials[0]), - execution_address=ExecutionAddress(withdrawal_credentials[12:]), + version=version, + execution_address=execution_address, balance=amount, deposit_epoch=compute_epoch_at_slot(slot), withdrawable_epoch=FAR_FUTURE_EPOCH, @@ -1563,7 +1665,7 @@ def add_builder_to_registry( ) ``` -###### New `apply_deposit_for_builder` +###### New `process_builder_deposit_request` *Note*: Builder indices are reusable. When a builder exits, its index may later be reassigned to a different builder with a new public key. Any deposit sent to @@ -1573,25 +1675,24 @@ may have previously appeared in the builder set. Implementations that rely on caching should account for this behavior. ```python -def apply_deposit_for_builder( - state: BeaconState, - pubkey: BLSPubkey, - withdrawal_credentials: Bytes32, - amount: uint64, - signature: BLSSignature, - slot: Slot, -) -> None: +def process_builder_deposit_request(state: BeaconState, request: BuilderDepositRequest) -> None: builder_pubkeys = [b.pubkey for b in state.builders] - if pubkey not in builder_pubkeys: - # Verify the deposit signature (proof of possession) which is not checked by the deposit contract - if is_valid_deposit_signature(pubkey, withdrawal_credentials, amount, signature): - add_builder_to_registry(state, pubkey, withdrawal_credentials, amount, slot) + if request.pubkey not in builder_pubkeys: + if is_valid_builder_deposit_signature(request): + add_builder_to_registry( + state, + request.pubkey, + uint8(request.withdrawal_credentials[0]), + ExecutionAddress(request.withdrawal_credentials[12:]), + request.amount, + state.slot, + ) else: - builder_index = builder_pubkeys.index(pubkey) + builder_index = BuilderIndex(builder_pubkeys.index(request.pubkey)) builder = state.builders[builder_index] # Increase balance by deposit amount - builder.balance += amount + builder.balance += request.amount # If exited, reset the withdrawable epoch if builder.withdrawable_epoch != FAR_FUTURE_EPOCH: @@ -1599,89 +1700,27 @@ def apply_deposit_for_builder( builder.withdrawable_epoch = epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY ``` -###### Modified `process_deposit_request` +##### Builder exit requests + +###### New `process_builder_exit_request` ```python -def process_deposit_request(state: BeaconState, deposit_request: DepositRequest) -> None: - # [New in Gloas:EIP7732] +def process_builder_exit_request(state: BeaconState, request: BuilderExitRequest) -> None: builder_pubkeys = [b.pubkey for b in state.builders] - validator_pubkeys = [v.pubkey for v in state.validators] - - # [New in Gloas:EIP7732] - # Regardless of the withdrawal credentials prefix, if a builder/validator - # already exists with this pubkey, apply the deposit to their balance - is_builder = deposit_request.pubkey in builder_pubkeys - is_validator = deposit_request.pubkey in validator_pubkeys - if is_builder or ( - is_builder_withdrawal_credential(deposit_request.withdrawal_credentials) - and not is_validator - and not is_pending_validator(state.pending_deposits, deposit_request.pubkey) - ): - # Apply builder deposits immediately - apply_deposit_for_builder( - state, - deposit_request.pubkey, - deposit_request.withdrawal_credentials, - deposit_request.amount, - deposit_request.signature, - state.slot, - ) + if request.pubkey not in builder_pubkeys: return - # Add validator deposits to the queue - state.pending_deposits.append( - PendingDeposit( - pubkey=deposit_request.pubkey, - withdrawal_credentials=deposit_request.withdrawal_credentials, - amount=deposit_request.amount, - signature=deposit_request.signature, - slot=state.slot, - ) - ) -``` - -##### Voluntary exits - -###### Modified `process_voluntary_exit` - -```python -def process_voluntary_exit(state: BeaconState, signed_voluntary_exit: SignedVoluntaryExit) -> None: - voluntary_exit = signed_voluntary_exit.message - domain = compute_domain( - DOMAIN_VOLUNTARY_EXIT, CAPELLA_FORK_VERSION, state.genesis_validators_root - ) - signing_root = compute_signing_root(voluntary_exit, domain) - - # Exits must specify an epoch when they become valid; they are not valid before then - assert get_current_epoch(state) >= voluntary_exit.epoch + builder_index = BuilderIndex(builder_pubkeys.index(request.pubkey)) + builder = state.builders[builder_index] - # [New in Gloas:EIP7732] - if is_builder_index(voluntary_exit.validator_index): - builder_index = convert_validator_index_to_builder_index(voluntary_exit.validator_index) - # Verify the builder is active - assert is_active_builder(state, builder_index) - # Only exit builder if it has no pending withdrawals in the queue - assert get_pending_balance_to_withdraw_for_builder(state, builder_index) == 0 - # Verify signature - pubkey = state.builders[builder_index].pubkey - assert bls.Verify(pubkey, signing_root, signed_voluntary_exit.signature) - # Initiate exit - initiate_builder_exit(state, builder_index) + if not is_active_builder(state, builder_index): + return + if builder.execution_address != request.source_address: + return + if get_pending_balance_to_withdraw_for_builder(state, builder_index) != 0: return - validator = state.validators[voluntary_exit.validator_index] - # Verify the validator is active - assert is_active_validator(validator, get_current_epoch(state)) - # Verify exit has not been initiated - assert validator.exit_epoch == FAR_FUTURE_EPOCH - # Verify the validator has been active long enough - assert get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD - # Only exit validator if it has no pending withdrawals in the queue - assert get_pending_balance_to_withdraw(state, voluntary_exit.validator_index) == 0 - # Verify signature - assert bls.Verify(validator.pubkey, signing_root, signed_voluntary_exit.signature) - # Initiate exit - initiate_validator_exit(state, voluntary_exit.validator_index) + initiate_builder_exit(state, builder_index) ``` ##### Attestations diff --git a/specs/gloas/builder.md b/specs/gloas/builder.md index 46863cd4dd4..d033fbfdb29 100644 --- a/specs/gloas/builder.md +++ b/specs/gloas/builder.md @@ -6,11 +6,11 @@ - [Introduction](#introduction) - [Becoming a builder](#becoming-a-builder) - - [Builder withdrawal credentials](#builder-withdrawal-credentials) - [Submit deposit](#submit-deposit) - [Process deposit](#process-deposit) - [Builder index](#builder-index) - [Activation](#activation) + - [Exiting](#exiting) - [Builder activities](#builder-activities) - [Constructing the `SignedExecutionPayloadBid`](#constructing-the-signedexecutionpayloadbid) - [Constructing the `DataColumnSidecar`s](#constructing-the-datacolumnsidecars) @@ -35,37 +35,32 @@ builders. ## Becoming a builder -### Builder withdrawal credentials - -When submitting a deposit to the deposit contract, the `withdrawal_credentials` -field determines whether the staked actor will be a validator or a builder. To -be recognized as a builder, the `withdrawal_credentials` must use the -`BUILDER_WITHDRAWAL_PREFIX`. - -The `withdrawal_credentials` field must be: - -- `withdrawal_credentials[:1] == BUILDER_WITHDRAWAL_PREFIX` (`0x03`) -- `withdrawal_credentials[1:12] == b'\x00' * 11` -- `withdrawal_credentials[12:] == builder_execution_address` - -Where `builder_execution_address` is an execution-layer address that will -receive withdrawals. - ### Submit deposit -Builders follow the same deposit process as validators, but with the -builder-specific withdrawal credentials. The deposit must include: +Builders are created by submitting a builder deposit request to the builder +deposit contract on the execution layer, as defined in +[EIP-8282](https://eips.ethereum.org/EIPS/eip-8282). The request must include: - `pubkey`: The builder's BLS public key. -- `withdrawal_credentials`: With the `BUILDER_WITHDRAWAL_PREFIX` (`0x03`) - prefix. +- `withdrawal_credentials`: The withdrawal credentials, where the first byte is + the builder version and the last 20 bytes are the execution-layer address that + will receive withdrawals. - `amount`: At least `MIN_DEPOSIT_AMOUNT` gwei. -- `signature`: BLS signature over the deposit data. +- `signature`: BLS proof of possession over the corresponding `DepositMessage` + under `DOMAIN_BUILDER_DEPOSIT`. + +*Note*: Builders may be onboarded at the fork by submitting a deposit to the +validator deposit contract with a `0x03` withdrawal credential. This must be +done late enough that the deposit is still pending at the fork, but early enough +that the slot in which the deposit is added to the pending deposit queue is +finalized so that the builder is considered active. Such a deposit signs over +`DepositMessage` under `DOMAIN_DEPOSIT`, with withdrawal credentials of the form +`BUILDER_WITHDRAWAL_PREFIX + b"\x00" * 11 + execution_address`. ### Process deposit -The beacon chain processes builder deposits identically to validator deposits, -with the withdrawal credentials using `BUILDER_WITHDRAWAL_PREFIX`. +A builder deposit request for a new pubkey registers a builder. A request for an +existing builder's pubkey tops up its balance. ### Builder index @@ -86,6 +81,19 @@ epoch of the pending deposit, not the fork epoch. Therefore, if that epoch is finalized at the fork, the builder will be immediately active. See `onboard_builders_from_pending_deposits` for details. +### Exiting + +A builder exits by submitting a builder exit request to the builder exit +contract on the execution layer, as defined in +[EIP-8282](https://eips.ethereum.org/EIPS/eip-8282). The request contains the +builder's `pubkey` and is authorized by the builder's `execution_address` (the +transaction sender), not the BLS key. + +The consensus layer initiates the exit only if the builder is active, the +request's `source_address` matches the builder's `execution_address`, and the +builder has no pending balance to withdraw. Otherwise the request is consumed +without effect and must be resubmitted once those conditions hold. + ## Builder activities Builders have two optional activities: submitting bids and submitting payloads. diff --git a/specs/gloas/fork.md b/specs/gloas/fork.md index 7fee974167f..1083af7ee10 100644 --- a/specs/gloas/fork.md +++ b/specs/gloas/fork.md @@ -57,6 +57,10 @@ def initialize_ptc_window( ### New `onboard_builders_from_pending_deposits` +*Note*: This one-time onboarding is the only path through the validator deposit +contract that creates builders. From the fork onward, builders are created and +topped up only via `BuilderDepositRequest`. + *Note*: In the slots leading up to the fork, implementations SHOULD validate pending deposit signatures and cache the results. The pending deposit queue might be large and verifying many signatures at the fork could be slow. @@ -76,9 +80,9 @@ def onboard_builders_from_pending_deposits(state: BeaconState) -> None: pending_deposits.append(deposit) continue - # Note that the function apply_deposit_for_builder can mutate the - # state and may add a builder to the registry. For this reason, the - # list of builder pubkeys must be recomputed each iteration. + # Note that applying a deposit below can mutate the state and + # may add a builder to the registry. For this reason, the list + # of builder pubkeys must be recomputed each iteration. builder_pubkeys = [b.pubkey for b in state.builders] # Deposits for non-builders stay in the pending queue. If there is a @@ -91,15 +95,25 @@ def onboard_builders_from_pending_deposits(state: BeaconState) -> None: if is_pending_validator(pending_deposits, deposit.pubkey): pending_deposits.append(deposit) continue + if not is_valid_deposit_signature( + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + deposit.signature, + ): + continue - apply_deposit_for_builder( - state, - deposit.pubkey, - deposit.withdrawal_credentials, - deposit.amount, - deposit.signature, - deposit.slot, - ) + add_builder_to_registry( + state, + deposit.pubkey, + uint8(deposit.withdrawal_credentials[0]), + ExecutionAddress(deposit.withdrawal_credentials[12:]), + deposit.amount, + deposit.slot, + ) + else: + builder_index = BuilderIndex(builder_pubkeys.index(deposit.pubkey)) + state.builders[builder_index].balance += deposit.amount state.pending_deposits = pending_deposits ``` diff --git a/specs/gloas/validator.md b/specs/gloas/validator.md index 26437889921..9ec9159fe1b 100644 --- a/specs/gloas/validator.md +++ b/specs/gloas/validator.md @@ -19,6 +19,7 @@ - [Signed execution payload bid](#signed-execution-payload-bid) - [Payload attestations](#payload-attestations) - [Parent execution requests](#parent-execution-requests) + - [Execution requests](#execution-requests) - [ExecutionPayload](#executionpayload) - [Voluntary exits](#voluntary-exits) - [Payload timeliness attestation](#payload-timeliness-attestation) @@ -249,6 +250,79 @@ parent's execution payload. The proposer constructs this field as follows: - Otherwise (the proposer is building on the parent's empty variant), set `parent_execution_requests` to an empty `ExecutionRequests()`. +##### Execution requests + +*Note*: The function `get_execution_requests` is modified to parse the builder +deposit requests and builder exit requests. + +```python +def get_execution_requests(execution_requests_list: Sequence[bytes]) -> ExecutionRequests: + deposits = [] + withdrawals = [] + consolidations = [] + # [New in Gloas:EIP8282] + builder_deposits = [] + # [New in Gloas:EIP8282] + builder_exits = [] + + request_types = [ + DEPOSIT_REQUEST_TYPE, + WITHDRAWAL_REQUEST_TYPE, + CONSOLIDATION_REQUEST_TYPE, + # [New in Gloas:EIP8282] + BUILDER_DEPOSIT_REQUEST_TYPE, + # [New in Gloas:EIP8282] + BUILDER_EXIT_REQUEST_TYPE, + ] + + prev_request_type = None + for request in execution_requests_list: + request_type, request_data = request[0:1], request[1:] + + # Check that the request type is valid + assert request_type in request_types + # Check that the request data is not empty + assert len(request_data) != 0 + # Check that requests are in strictly ascending order + # Each successive type must be greater than the last with no duplicates + assert prev_request_type is None or prev_request_type < request_type + prev_request_type = request_type + + if request_type == DEPOSIT_REQUEST_TYPE: + deposits = ssz_deserialize( + List[DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD], request_data + ) + elif request_type == WITHDRAWAL_REQUEST_TYPE: + withdrawals = ssz_deserialize( + List[WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD], request_data + ) + elif request_type == CONSOLIDATION_REQUEST_TYPE: + consolidations = ssz_deserialize( + List[ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD], request_data + ) + # [New in Gloas:EIP8282] + elif request_type == BUILDER_DEPOSIT_REQUEST_TYPE: + builder_deposits = ssz_deserialize( + List[BuilderDepositRequest, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD], + request_data, + ) + # [New in Gloas:EIP8282] + elif request_type == BUILDER_EXIT_REQUEST_TYPE: + builder_exits = ssz_deserialize( + List[BuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD], request_data + ) + + return ExecutionRequests( + deposits=deposits, + withdrawals=withdrawals, + consolidations=consolidations, + # [New in Gloas:EIP8282] + builder_deposits=builder_deposits, + # [New in Gloas:EIP8282] + builder_exits=builder_exits, + ) +``` + ##### ExecutionPayload *Note*: `prepare_execution_payload` is modified to build on the parent's full diff --git a/specs/heze/beacon-chain.md b/specs/heze/beacon-chain.md index a43d829d2f8..21bf4f40c85 100644 --- a/specs/heze/beacon-chain.md +++ b/specs/heze/beacon-chain.md @@ -34,7 +34,7 @@ Heze is a consensus-layer upgrade containing a number of features. Including: | Name | Value | | --------------------------------- | -------------------------- | -| `DOMAIN_INCLUSION_LIST_COMMITTEE` | `DomainType('0x0E000000')` | +| `DOMAIN_INCLUSION_LIST_COMMITTEE` | `DomainType('0x10000000')` | ## Preset diff --git a/tests/core/pyspec/eth_consensus_specs/test/electra/unittests/test_execution_requests.py b/tests/core/pyspec/eth_consensus_specs/test/electra/unittests/test_execution_requests.py index d2153ddf9e8..2bfac7856ba 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/electra/unittests/test_execution_requests.py +++ b/tests/core/pyspec/eth_consensus_specs/test/electra/unittests/test_execution_requests.py @@ -99,6 +99,6 @@ def test_requests_deserialize__reject_empty_request(spec): @single_phase def test_requests_deserialize__reject_unexpected_request_type(spec): serialized_execution_requests = [ - b"\x03\xff\xff\xff", + b"\xff\xff\xff\xff", ] expect_assertion_error(lambda: spec.get_execution_requests(serialized_execution_requests)) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_deposit_request.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_deposit_request.py new file mode 100644 index 00000000000..1581219672e --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_deposit_request.py @@ -0,0 +1,758 @@ +from eth_consensus_specs.test.context import always_bls, spec_state_test, with_gloas_and_later +from eth_consensus_specs.test.helpers.deposits import prepare_builder_deposit_request +from eth_consensus_specs.test.helpers.keys import builder_pubkey_to_privkey, privkeys, pubkeys +from eth_consensus_specs.utils import bls +from tests.infra.helpers.builder_deposit_requests import ( + assert_process_builder_deposit_request, + prepare_process_builder_deposit_request, + run_builder_deposit_request_processing, +) + + +def run_builder_deposit_processing( + spec, state, builder_deposit_request, is_new_builder=True, valid=True +): + """ + Run ``process_builder_deposit_request``, yielding: + - pre-state ('pre') + - builder_deposit_request ('builder_deposit_request') + - post-state ('post'). + + Args: + is_new_builder: If True, expect a new builder to be created. + If False, expect a top-up of an existing builder. + valid: If True, expect the deposit to be applied (new builder or top-up). + If False, expect no changes (invalid signature, wrong credentials, etc). + """ + pre_state = state.copy() + pre_builder_count = len(state.builders) + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + if not valid: + # Invalid deposit should not change state (builder not created) + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + state_unchanged=True, + ) + elif is_new_builder: + # New builder should be added to registry + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_balance=builder_deposit_request.amount, + expected_execution_address=spec.ExecutionAddress( + builder_deposit_request.withdrawal_credentials[12:] + ), + expected_builder_withdrawable_epoch=spec.FAR_FUTURE_EPOCH, + ) + else: + # Top-up should increase balance + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=pre_builder_count, + expected_builder_balance_delta=builder_deposit_request.amount, + ) + + +# +# New builder deposits +# + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder(spec, state): + """Test fresh builder deposit creates a new builder.""" + amount = spec.MIN_DEPOSIT_AMOUNT + builder_deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) + + yield from run_builder_deposit_processing(spec, state, builder_deposit_request) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_nonzero_version(spec, state): + """ + Test fresh builder deposit with a non-zero version. + + The version (the first byte of the withdrawal credentials) is not + constrained: it is recorded on the builder verbatim and is committed to by + the proof of possession. + """ + amount = spec.MIN_DEPOSIT_AMOUNT + version = spec.uint8(7) + withdrawal_credentials = bytes([version]) + b"\x00" * 11 + b"\x42" * 20 + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, withdrawal_credentials=withdrawal_credentials, signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_balance=amount, + expected_execution_address=spec.ExecutionAddress( + builder_deposit_request.withdrawal_credentials[12:] + ), + expected_builder_withdrawable_epoch=spec.FAR_FUTURE_EPOCH, + ) + + builder_index = [b.pubkey for b in state.builders].index(builder_deposit_request.pubkey) + assert state.builders[builder_index].version == version + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_large_amount(spec, state): + """Test fresh builder deposit with a large amount.""" + # 1000 ETH deposit + amount = spec.Gwei(1_000 * spec.ETH_TO_GWEI) + builder_deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) + + yield from run_builder_deposit_processing(spec, state, builder_deposit_request) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_very_large_amount(spec, state): + """Test fresh builder deposit with a very large amount.""" + # 10k ETH deposit + amount = spec.Gwei(10_000 * spec.ETH_TO_GWEI) + builder_deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) + + yield from run_builder_deposit_processing(spec, state, builder_deposit_request) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_extra_gwei(spec, state): + """ + Test builder deposit with non-round amount (extra gwei). + + Input State Configured: + - Valid beacon state with existing builders + - Builder deposit request with amount = MIN_DEPOSIT_AMOUNT + 1 (non-round) + + Output State Verified: + - New builder created + - Builder balance equals exact deposit amount (including extra gwei) + """ + amount = spec.MIN_DEPOSIT_AMOUNT + spec.Gwei(1) + builder_deposit_request = prepare_process_builder_deposit_request( + spec, state, amount=amount, signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_balance=amount, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_max_minus_one(spec, state): + """ + Test builder deposit with amount = MAX_EFFECTIVE_BALANCE - 1. + + Input State Configured: + - Valid beacon state with existing builders + - Builder deposit request with amount just below max effective balance + + Output State Verified: + - New builder created + - Builder balance equals MAX_EFFECTIVE_BALANCE - 1 + """ + amount = spec.MAX_EFFECTIVE_BALANCE - 1 + builder_deposit_request = prepare_process_builder_deposit_request( + spec, state, amount=amount, signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_balance=amount, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_empty_registry(spec, state): + """ + Test new builder deposit when state.builders is empty. + + Input State Configured: + - Empty builders registry + + Output State Verified: + - New builder appended as index 0 + - Builder has correct fields including withdrawable_epoch = FAR_FUTURE_EPOCH + """ + amount = spec.MIN_DEPOSIT_AMOUNT + # Clear builders registry via the helper + builder_deposit_request = prepare_process_builder_deposit_request( + spec, state, amount=amount, signed=True, builders=[] + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=1, + expected_builder_index=0, + expected_builder_balance=amount, + expected_builder_withdrawable_epoch=spec.FAR_FUTURE_EPOCH, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__new_builder_pubkey_is_validator(spec, state): + """ + Test that a builder deposit request registers a builder even when the + pubkey already belongs to a validator. The validator and builder + registries are keyed independently, so one pubkey may be both. + """ + validator_pubkey = state.validators[0].pubkey + assert validator_pubkey == pubkeys[0] + amount = spec.MIN_DEPOSIT_AMOUNT + pre_builder_count = len(state.builders) + + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, pubkey=validator_pubkey, privkey=privkeys[0], signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=pre_builder_count + 1, + expected_builder_balance=amount, + ) + + +# +# Top-up deposits for existing builders +# + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__top_up(spec, state): + """ + Test builder top-up with builder credentials. + + Input State Configured: + - Existing builder pubkey + - Builder withdrawal credentials (0x03 prefix) + + Output State Verified: + - Builder balance increased (top-up) + - No pending deposits added + """ + builder_pubkey = state.builders[0].pubkey + amount = spec.MIN_DEPOSIT_AMOUNT + pre_balance = state.builders[0].balance + pre_builder_count = len(state.builders) + + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, pubkey=builder_pubkey, signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=pre_builder_count, + expected_builder_balance=pre_balance + amount, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__top_up_large(spec, state): + """Test large top-up deposit for an existing builder.""" + # Large top-up (500 ETH) + amount = spec.Gwei(500 * spec.ETH_TO_GWEI) + pubkey = state.builders[0].pubkey + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, pubkey=pubkey, signed=True + ) + + yield from run_builder_deposit_processing( + spec, state, builder_deposit_request, is_new_builder=False + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__top_up_single_builder(spec, state): + """ + Test top-up with single builder in registry. + + Input State Configured: + - Exactly one builder in registry + + Output State Verified: + - Builder balance increased + - Builder count unchanged (still 1) + """ + # Keep only one builder + first_builder = state.builders[0] + pre_balance = first_builder.balance + amount = spec.MIN_DEPOSIT_AMOUNT + + # Top-up existing builder at index 0, keeping only one builder + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + builder_index=0, + amount=amount, + signed=True, + builders=[first_builder], + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=1, + expected_builder_balance=pre_balance + amount, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__top_up_last_index(spec, state): + """ + Test top-up targeting last builder in registry. + + Input State Configured: + - Multiple builders in registry + - Top-up targets last builder + + Output State Verified: + - Last builder's balance increased + - Other builders unchanged (verified by assert_process_builder_deposit_request invariant) + """ + last_index = len(state.builders) - 1 + pubkey = state.builders[last_index].pubkey + pre_balance = state.builders[last_index].balance + pre_builder_count = len(state.builders) + amount = spec.MIN_DEPOSIT_AMOUNT + + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, pubkey=pubkey, signed=True + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + # assert_process_builder_deposit_request verifies other builders unchanged for top-ups + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=pre_builder_count, + expected_builder_index=last_index, + expected_builder_balance=pre_balance + amount, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__top_up_ignores_request_fields(spec, state): + """ + Test that a top-up for an existing builder ignores the supplied withdrawal + credentials. The existing registration is unchanged. + """ + builder_pubkey = state.builders[0].pubkey + amount = spec.MIN_DEPOSIT_AMOUNT + pre_balance = state.builders[0].balance + pre_builder_count = len(state.builders) + + # Use withdrawal credentials that differ from the registration + withdrawal_credentials = b"\x07" + b"\x00" * 11 + b"\x42" * 20 + assert state.builders[0].version != spec.uint8(withdrawal_credentials[0]) + assert state.builders[0].execution_address != spec.ExecutionAddress(withdrawal_credentials[12:]) + + builder_deposit_request = prepare_builder_deposit_request( + spec, + state, + amount, + pubkey=builder_pubkey, + withdrawal_credentials=withdrawal_credentials, + signed=True, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + # Should top up the existing builder (other request fields are ignored) + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + expected_builder_count=pre_builder_count, + expected_builder_balance=pre_balance + amount, + ) + assert state.builders[0].version == pre_state.builders[0].version + assert state.builders[0].execution_address == pre_state.builders[0].execution_address + + +# +# Invalid deposits +# + + +@with_gloas_and_later +@spec_state_test +@always_bls +def test_process_builder_deposit_request__new_builder_invalid_sig(spec, state): + """Test that new builder deposit with invalid signature is dropped.""" + amount = spec.MIN_DEPOSIT_AMOUNT + # Don't sign the deposit + builder_deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=False) + + yield from run_builder_deposit_processing( + spec, state, builder_deposit_request, is_new_builder=True, valid=False + ) + + +@with_gloas_and_later +@spec_state_test +@always_bls +def test_process_builder_deposit_request__new_builder_replayed_validator_sig(spec, state): + """ + Test that a builder deposit signed under DOMAIN_DEPOSIT is dropped. + + A signature over the same DepositMessage under DOMAIN_DEPOSIT is a valid + validator deposit signature. It must not be accepted here, otherwise any + validator deposit could be replayed to the builder deposit contract to + register its pubkey as a builder. + """ + amount = spec.MIN_DEPOSIT_AMOUNT + builder_deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=False) + + # Sign over the validator deposit domain instead of DOMAIN_BUILDER_DEPOSIT + deposit_message = spec.DepositMessage( + pubkey=builder_deposit_request.pubkey, + withdrawal_credentials=builder_deposit_request.withdrawal_credentials, + amount=builder_deposit_request.amount, + ) + domain = spec.compute_domain(spec.DOMAIN_DEPOSIT) + signing_root = spec.compute_signing_root(deposit_message, domain) + privkey = builder_pubkey_to_privkey[builder_deposit_request.pubkey] + builder_deposit_request.signature = bls.Sign(privkey, signing_root) + + yield from run_builder_deposit_processing( + spec, state, builder_deposit_request, is_new_builder=True, valid=False + ) + + +@with_gloas_and_later +@spec_state_test +@always_bls +def test_process_builder_deposit_request__top_up_invalid_sig(spec, state): + """Test that top-up deposit with invalid signature still succeeds for existing builders.""" + amount = spec.MIN_DEPOSIT_AMOUNT + pubkey = state.builders[0].pubkey + # Don't sign the deposit + builder_deposit_request = prepare_builder_deposit_request( + spec, state, amount, pubkey=pubkey, signed=False + ) + + # Top-ups don't require signature verification for existing builders + yield from run_builder_deposit_processing( + spec, state, builder_deposit_request, is_new_builder=False + ) + + +# +# Builder slot reuse +# + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__reuses_exited_builder_slot(spec, state): + """Test that new builder can reuse slot of fully exited builder with zero balance.""" + pre_builder_count = len(state.builders) + + # Advance epochs and make builder 0 exited with zero balance + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + amount=spec.MIN_DEPOSIT_AMOUNT, + signed=True, + advance_epochs=1, + builder_modifications={0: {"withdrawable_epoch": "current_epoch-1", "balance": 0}}, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + slot_reused=True, + expected_builder_count=pre_builder_count, + expected_builder_index=0, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__reuses_first_of_multiple_exited_slots(spec, state): + """ + Test that first reusable slot is selected when multiple slots are available. + + Input State Configured: + - Builder at index 0: exited with zero balance (reusable) + - Builder at index 1: exited with zero balance (reusable) + - Builder at index 2: active (not reusable) + + Output State Verified: + - First reusable slot (index 0) is used + - Builder count unchanged + """ + pre_builder_count = len(state.builders) + + # Advance epochs and make builders 0 and 1 both reusable (exited with zero balance) + # Builder 2 stays active (default FAR_FUTURE_EPOCH) + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + amount=spec.MIN_DEPOSIT_AMOUNT, + signed=True, + advance_epochs=1, + builder_modifications={ + 0: {"withdrawable_epoch": "current_epoch-1", "balance": 0}, + 1: {"withdrawable_epoch": "current_epoch-1", "balance": 0}, + }, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + slot_reused=True, + expected_builder_count=pre_builder_count, + expected_builder_index=0, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__reuses_slot_at_current_epoch(spec, state): + """ + Test slot reuse when withdrawable_epoch == current_epoch. + + Input State Configured: + - Builder at index 0: withdrawable_epoch = current_epoch, balance = 0 + + Output State Verified: + - Slot IS reusable at exact epoch boundary + - New builder placed at index 0 + """ + pre_builder_count = len(state.builders) + + # Advance epochs and make builder 0 reusable exactly at current epoch + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + amount=spec.MIN_DEPOSIT_AMOUNT, + signed=True, + advance_epochs=1, + builder_modifications={0: {"withdrawable_epoch": "current_epoch", "balance": 0}}, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + slot_reused=True, + expected_builder_count=pre_builder_count, + expected_builder_index=0, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__no_reuse_future_epoch(spec, state): + """ + Test slot NOT reusable when withdrawable_epoch == current_epoch + 1. + + Input State Configured: + - Builder at index 0: withdrawable_epoch = current_epoch + 1, balance = 0 + + Output State Verified: + - Slot is NOT reusable (epoch in future) + - New builder appended to registry + - Original builders unchanged + """ + pre_builder_count = len(state.builders) + + # Advance epochs and make builder 0 NOT yet reusable (one epoch in future) + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + amount=spec.MIN_DEPOSIT_AMOUNT, + signed=True, + advance_epochs=1, + builder_modifications={0: {"withdrawable_epoch": "current_epoch+1", "balance": 0}}, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + # slot_reused=False also verifies original builders are unchanged + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + slot_reused=False, + expected_builder_count=pre_builder_count + 1, + expected_builder_index=pre_builder_count, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__no_reuse_nonzero_balance(spec, state): + """ + Test slot NOT reusable when balance == 1. + + Input State Configured: + - Builder at index 0: withdrawable_epoch in past, balance = 1 (minimum non-zero) + + Output State Verified: + - Slot is NOT reusable (non-zero balance blocks reuse) + - New builder appended to registry + - Original builders unchanged + """ + pre_builder_count = len(state.builders) + + # Advance epochs and make builder 0 exited but with minimum non-zero balance + builder_deposit_request = prepare_process_builder_deposit_request( + spec, + state, + amount=spec.MIN_DEPOSIT_AMOUNT, + signed=True, + advance_epochs=1, + builder_modifications={0: {"withdrawable_epoch": "current_epoch-1", "balance": 1}}, + ) + pre_state = state.copy() + + yield from run_builder_deposit_request_processing(spec, state, builder_deposit_request) + + # slot_reused=False also verifies original builders are unchanged + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=builder_deposit_request, + slot_reused=False, + expected_builder_count=pre_builder_count + 1, + expected_builder_index=pre_builder_count, + ) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_deposit_request__exited_builder_top_up(spec, state): + """ + Test top-up to an exited builder resets its withdrawable epoch. + + Input State Configured: + - Existing builder at index 0 that has exited (withdrawable_epoch in the past) + + Output State Verified: + - Builder balance increased (top-up) + - withdrawable_epoch reset to current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY + """ + builder_pubkey = state.builders[0].pubkey + amount = spec.MIN_DEPOSIT_AMOUNT + pre_builder_count = len(state.builders) + + # Advance an epoch and mark builder 0 as exited (withdrawable_epoch in the past) + deposit_request = prepare_process_builder_deposit_request( + spec, + state, + pubkey=builder_pubkey, + amount=amount, + signed=True, + advance_epochs=1, + builder_modifications={0: {"withdrawable_epoch": "current_epoch-1"}}, + ) + pre_state = state.copy() + expected_withdrawable_epoch = ( + spec.get_current_epoch(state) + spec.config.MIN_BUILDER_WITHDRAWABILITY_DELAY + ) + # Sanity check: the exited epoch differs from the expected reset value + assert state.builders[0].withdrawable_epoch != expected_withdrawable_epoch + + yield from run_builder_deposit_request_processing(spec, state, deposit_request) + + assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request=deposit_request, + expected_builder_count=pre_builder_count, + expected_builder_index=0, + expected_builder_balance_delta=amount, + expected_builder_withdrawable_epoch=expected_withdrawable_epoch, + ) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_exit_request.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_exit_request.py new file mode 100644 index 00000000000..3379fbc3936 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_builder_exit_request.py @@ -0,0 +1,197 @@ +from eth_consensus_specs.test.context import spec_state_test, with_gloas_and_later +from eth_consensus_specs.test.helpers.keys import builder_pubkeys +from eth_consensus_specs.test.helpers.state import next_slots + + +def advance_past_finalization(spec, state): + """Advance slots and finalize so that genesis-epoch builders become active.""" + epoch = spec.get_current_epoch(state) + next_slots(spec, state, spec.SLOTS_PER_EPOCH * 3) + state.finalized_checkpoint.epoch = epoch + 1 + + +def prepare_builder_exit_request(spec, state, builder_index, source_address=None): + """ + Create a builder exit request for the builder at the given index, + authorized by its execution address unless source_address is provided. + """ + builder = state.builders[builder_index] + if source_address is None: + source_address = builder.execution_address + return spec.BuilderExitRequest( + source_address=source_address, + pubkey=builder.pubkey, + ) + + +def run_builder_exit_request_processing(spec, state, builder_exit_request, valid=True): + """ + Run ``process_builder_exit_request``, yielding: + - pre-state ('pre') + - builder_exit_request ('builder_exit_request') + - post-state ('post'). + + The function never raises. If valid is False, expect the request to be + consumed without changing the state. + """ + pre_state = state.copy() + + yield "pre", state + yield "builder_exit_request", builder_exit_request + + spec.process_builder_exit_request(state, builder_exit_request) + + yield "post", state + + if not valid: + assert state == pre_state + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__success(spec, state): + """Test successful builder exit with no pending balance.""" + builder_index = 0 + + advance_past_finalization(spec, state) + assert spec.is_active_builder(state, builder_index) + assert spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) == 0 + + current_epoch = spec.get_current_epoch(state) + builder_exit_request = prepare_builder_exit_request(spec, state, builder_index) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request) + + assert not spec.is_active_builder(state, builder_index) + expected_withdrawable = current_epoch + spec.config.MIN_BUILDER_WITHDRAWABILITY_DELAY + assert state.builders[builder_index].withdrawable_epoch == expected_withdrawable + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__unknown_pubkey(spec, state): + """Test that an exit request for an unknown pubkey is a no-op.""" + advance_past_finalization(spec, state) + + # Use a pubkey that is not in the builder registry + existing_pubkeys = {builder.pubkey for builder in state.builders} + unknown_pubkey = None + for pk in builder_pubkeys: + if pk not in existing_pubkeys: + unknown_pubkey = pk + break + assert unknown_pubkey is not None + + builder_exit_request = spec.BuilderExitRequest( + source_address=spec.ExecutionAddress(b"\x42" * 20), + pubkey=unknown_pubkey, + ) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__inactive_deposit_epoch(spec, state): + """Test that an inactive builder (deposit epoch not finalized) cannot exit.""" + builder_index = 0 + + # Set builder's deposit epoch to a non-finalized epoch + state.builders[builder_index].deposit_epoch = spec.Epoch(1) + + advance_past_finalization(spec, state) + assert state.finalized_checkpoint.epoch == state.builders[builder_index].deposit_epoch + assert not spec.is_active_builder(state, builder_index) + + builder_exit_request = prepare_builder_exit_request(spec, state, builder_index) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__already_exited(spec, state): + """Test that an already-exited builder cannot exit again.""" + builder_index = 0 + + # Set builder's withdrawable epoch which indicates it has initiated an exit + state.builders[builder_index].withdrawable_epoch = spec.get_current_epoch(state) + 10 + + advance_past_finalization(spec, state) + assert not spec.is_active_builder(state, builder_index) + + builder_exit_request = prepare_builder_exit_request(spec, state, builder_index) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__wrong_source_address(spec, state): + """Test that an exit request from the wrong source address is a no-op.""" + builder_index = 0 + + advance_past_finalization(spec, state) + assert spec.is_active_builder(state, builder_index) + + # Use a source address that differs from the builder's execution address + wrong_address = spec.ExecutionAddress(b"\x42" * 20) + assert state.builders[builder_index].execution_address != wrong_address + builder_exit_request = prepare_builder_exit_request( + spec, state, builder_index, source_address=wrong_address + ) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__pending_withdrawal(spec, state): + """Test that a builder cannot exit while having a pending withdrawal.""" + builder_index = 0 + + advance_past_finalization(spec, state) + assert spec.is_active_builder(state, builder_index) + + # Add pending withdrawal for this builder + withdrawal_amount = spec.MIN_ACTIVATION_BALANCE + withdrawal = spec.BuilderPendingWithdrawal( + fee_recipient=spec.ExecutionAddress(b"\x70" * 20), + amount=withdrawal_amount, + builder_index=builder_index, + ) + state.builder_pending_withdrawals.append(withdrawal) + pending_balance = spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) + assert pending_balance == withdrawal_amount + + builder_exit_request = prepare_builder_exit_request(spec, state, builder_index) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) + + +@with_gloas_and_later +@spec_state_test +def test_process_builder_exit_request__pending_payment(spec, state): + """Test that a builder cannot exit while having a pending payment.""" + builder_index = 0 + + advance_past_finalization(spec, state) + assert spec.is_active_builder(state, builder_index) + + # Add pending payment for this builder + payment_amount = spec.MIN_ACTIVATION_BALANCE + payment = spec.BuilderPendingPayment( + weight=spec.get_builder_payment_quorum_threshold(state) + 1, + withdrawal=spec.BuilderPendingWithdrawal( + fee_recipient=spec.ExecutionAddress(b"\x60" * 20), + amount=payment_amount, + builder_index=builder_index, + ), + ) + state.builder_pending_payments[0] = payment + pending_balance = spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) + assert pending_balance == payment_amount + + builder_exit_request = prepare_builder_exit_request(spec, state, builder_index) + + yield from run_builder_exit_request_processing(spec, state, builder_exit_request, valid=False) diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_deposit_request.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_deposit_request.py index b99d6d8d32b..c0573585995 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_deposit_request.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_deposit_request.py @@ -1,437 +1,41 @@ -from eth_consensus_specs.test.context import always_bls, spec_state_test, with_gloas_and_later -from eth_consensus_specs.test.helpers.deposits import ( - make_withdrawal_credentials, - prepare_builder_deposit_request, - prepare_deposit_request, - prepare_pending_deposit, -) -from eth_consensus_specs.test.helpers.keys import ( - builder_pubkey_to_privkey, - pubkeys, -) +from eth_consensus_specs.test.context import spec_state_test, with_gloas_and_later from tests.infra.helpers.deposit_requests import ( assert_process_deposit_request, prepare_process_deposit_request, run_deposit_request_processing, ) - -def run_builder_deposit_request_processing( - spec, state, deposit_request, is_new_builder=True, valid=True -): - """ - Run ``process_deposit_request`` for a builder deposit, yielding: - - pre-state ('pre') - - deposit_request ('deposit_request') - - post-state ('post'). - - Args: - is_new_builder: If True, expect a new builder to be created. - If False, expect a top-up of an existing builder. - valid: If True, expect the deposit to be applied (new builder or top-up). - If False, expect no changes (invalid signature, pubkey already validator, etc). - """ - pre_state = state.copy() - pre_builder_count = len(state.builders) - - yield "pre", state - yield "deposit_request", deposit_request - - spec.process_deposit_request(state, deposit_request) - - yield "post", state - - if not valid: - # Invalid deposit should not change state (builder not created) - assert_process_deposit_request( - spec, - state, - pre_state, - state_unchanged=True, - ) - elif is_new_builder: - # New builder should be added to registry - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_balance=deposit_request.amount, - expected_execution_address=spec.ExecutionAddress( - deposit_request.withdrawal_credentials[12:] - ), - expected_builder_withdrawable_epoch=spec.FAR_FUTURE_EPOCH, - ) - else: - # Top-up should increase balance - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count, - expected_builder_balance_delta=deposit_request.amount, - ) - - -# -# New builder deposits -# - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder(spec, state): - """Test fresh builder deposit creates a new builder.""" - amount = spec.MIN_DEPOSIT_AMOUNT - deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) - - yield from run_builder_deposit_request_processing(spec, state, deposit_request) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder_large_amount(spec, state): - """Test fresh builder deposit with a large amount.""" - # 1000 ETH deposit - amount = spec.Gwei(1_000 * spec.ETH_TO_GWEI) - deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) - - yield from run_builder_deposit_request_processing(spec, state, deposit_request) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder_very_large_amount(spec, state): - """Test fresh builder deposit with a very large amount.""" - # 10k ETH deposit - amount = spec.Gwei(10_000 * spec.ETH_TO_GWEI) - deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=True) - - yield from run_builder_deposit_request_processing(spec, state, deposit_request) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder_extra_gwei(spec, state): - """ - Test builder deposit with non-round amount (extra gwei). - - Input State Configured: - - Valid beacon state with existing builders - - Deposit request with amount = MIN_DEPOSIT_AMOUNT + 1 (non-round) - - Output State Verified: - - New builder created - - Builder balance equals exact deposit amount (including extra gwei) - """ - amount = spec.MIN_DEPOSIT_AMOUNT + spec.Gwei(1) - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_balance=amount, - ) - - # -# Top-up deposits for existing builders +# In Gloas, deposit requests never create or top up builders. Builders are +# created and topped up only via builder deposit requests. These tests verify +# that deposits with builder withdrawal credentials, or for pubkeys that are +# already builders, are queued as ordinary pending deposits. # @with_gloas_and_later @spec_state_test -def test_process_deposit_request__builder_top_up(spec, state): +def test_process_deposit_request__builder_credentials_queued(spec, state): """ - Test builder top-up with builder credentials. + Test that a deposit with builder withdrawal credentials is queued. Input State Configured: - - Existing builder pubkey + - New pubkey (not an existing validator or builder) - Builder withdrawal credentials (0x03 prefix) Output State Verified: - - Builder balance increased (top-up) - - No pending deposits added - - Credentials field is ignored for existing builder lookup - """ - builder_pubkey = state.builders[0].pubkey - amount = spec.MIN_DEPOSIT_AMOUNT - pre_balance = state.builders[0].balance - pre_builder_count = len(state.builders) - - # Top-up existing builder using its pubkey with builder credentials (default for for_builder) - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, pubkey=builder_pubkey, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count, - expected_builder_balance=pre_balance + amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__builder_top_up_large(spec, state): - """Test large top-up deposit for an existing builder.""" - # Large top-up (500 ETH) - amount = spec.Gwei(500 * spec.ETH_TO_GWEI) - pubkey = state.builders[0].pubkey - deposit_request = prepare_builder_deposit_request( - spec, state, amount, pubkey=pubkey, signed=True - ) - - yield from run_builder_deposit_request_processing( - spec, state, deposit_request, is_new_builder=False - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__exited_builder_top_up_resets_withdrawable_epoch(spec, state): - """ - Test top-up to an exited builder resets its withdrawable epoch. - - Input State Configured: - - Existing builder at index 0 that has exited (withdrawable_epoch in the past) - - Output State Verified: - - Builder balance increased (top-up) - - withdrawable_epoch reset to current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY + - Pending deposit added to the validator queue + - No builder created (builder count unchanged) """ - builder_pubkey = state.builders[0].pubkey amount = spec.MIN_DEPOSIT_AMOUNT - pre_builder_count = len(state.builders) - - # Advance an epoch and mark builder 0 as exited (withdrawable_epoch in the past) + # Builder withdrawal credentials (0x03 prefix) on an otherwise ordinary deposit + withdrawal_credentials = spec.BUILDER_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 deposit_request = prepare_process_deposit_request( spec, state, - for_builder=True, - pubkey=builder_pubkey, amount=amount, signed=True, - advance_epochs=1, - builder_modifications={0: {"withdrawable_epoch": "current_epoch-1"}}, - ) - pre_state = state.copy() - expected_withdrawable_epoch = ( - spec.get_current_epoch(state) + spec.config.MIN_BUILDER_WITHDRAWABILITY_DELAY - ) - # Sanity check: the exited epoch differs from the expected reset value - assert state.builders[0].withdrawable_epoch != expected_withdrawable_epoch - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count, - expected_builder_index=0, - expected_builder_balance_delta=amount, - expected_builder_withdrawable_epoch=expected_withdrawable_epoch, - ) - - -# -# Invalid signature tests -# - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__new_builder_invalid_sig(spec, state): - """Test that new builder deposit with invalid signature is rejected.""" - amount = spec.MIN_DEPOSIT_AMOUNT - # Don't sign the deposit - deposit_request = prepare_builder_deposit_request(spec, state, amount, signed=False) - - yield from run_builder_deposit_request_processing( - spec, state, deposit_request, is_new_builder=True, valid=False - ) - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__builder_top_up_invalid_sig(spec, state): - """Test that top-up deposit with invalid signature still succeeds for existing builders.""" - amount = spec.MIN_DEPOSIT_AMOUNT - pubkey = state.builders[0].pubkey - # Don't sign the deposit - deposit_request = prepare_builder_deposit_request( - spec, state, amount, pubkey=pubkey, signed=False - ) - - # Top-ups don't require signature verification for existing builders - yield from run_builder_deposit_request_processing( - spec, state, deposit_request, is_new_builder=False - ) - - -# -# Edge cases -# - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder_max_minus_one(spec, state): - """ - Test builder deposit with amount = MAX_EFFECTIVE_BALANCE - 1. - - Input State Configured: - - Valid beacon state with existing builders - - Deposit request with amount just below max effective balance - - Output State Verified: - - New builder created - - Builder balance equals MAX_EFFECTIVE_BALANCE - 1 - """ - amount = spec.MAX_EFFECTIVE_BALANCE - 1 - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_balance=amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__reuses_exited_builder_slot(spec, state): - """Test that new builder can reuse slot of fully exited builder with zero balance.""" - pre_builder_count = len(state.builders) - - # Advance epochs and make builder 0 exited with zero balance - deposit_request = prepare_process_deposit_request( - spec, - state, - for_builder=True, - amount=spec.MIN_DEPOSIT_AMOUNT, - signed=True, - advance_epochs=1, - builder_modifications={0: {"withdrawable_epoch": "current_epoch-1", "balance": 0}}, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - slot_reused=True, - expected_builder_count=pre_builder_count, - expected_builder_index=0, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__reuses_first_of_multiple_exited_slots(spec, state): - """ - Test that first reusable slot is selected when multiple slots are available. - - Input State Configured: - - Builder at index 0: exited with zero balance (reusable) - - Builder at index 1: exited with zero balance (reusable) - - Builder at index 2: active (not reusable) - - Output State Verified: - - First reusable slot (index 0) is used - - Builder count unchanged - """ - pre_builder_count = len(state.builders) - - # Advance epochs and make builders 0 and 1 both reusable (exited with zero balance) - # Builder 2 stays active (default FAR_FUTURE_EPOCH) - deposit_request = prepare_process_deposit_request( - spec, - state, - for_builder=True, - amount=spec.MIN_DEPOSIT_AMOUNT, - signed=True, - advance_epochs=1, - builder_modifications={ - 0: {"withdrawable_epoch": "current_epoch-1", "balance": 0}, - 1: {"withdrawable_epoch": "current_epoch-1", "balance": 0}, - }, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - slot_reused=True, - expected_builder_count=pre_builder_count, - expected_builder_index=0, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__reuses_slot_at_current_epoch(spec, state): - """ - Test slot reuse when withdrawable_epoch == current_epoch. - - Input State Configured: - - Builder at index 0: withdrawable_epoch = current_epoch, balance = 0 - - Output State Verified: - - Slot IS reusable at exact epoch boundary - - New builder placed at index 0 - """ - pre_builder_count = len(state.builders) - - # Advance epochs and make builder 0 reusable exactly at current epoch - deposit_request = prepare_process_deposit_request( - spec, - state, - for_builder=True, - amount=spec.MIN_DEPOSIT_AMOUNT, - signed=True, - advance_epochs=1, - builder_modifications={0: {"withdrawable_epoch": "current_epoch", "balance": 0}}, + withdrawal_credentials=withdrawal_credentials, ) pre_state = state.copy() @@ -442,116 +46,78 @@ def test_process_deposit_request__reuses_slot_at_current_epoch(spec, state): state, pre_state, deposit_request=deposit_request, - is_builder_deposit=True, - slot_reused=True, - expected_builder_count=pre_builder_count, - expected_builder_index=0, + expected_pending_deposit_pubkey=deposit_request.pubkey, + expected_pending_deposit_amount=amount, + expected_pending_deposit_credentials=deposit_request.withdrawal_credentials, ) @with_gloas_and_later @spec_state_test -def test_process_deposit_request__no_reuse_future_epoch(spec, state): +def test_process_deposit_request__builder_pubkey_queued(spec, state): """ - Test slot NOT reusable when withdrawable_epoch == current_epoch + 1. + Test that a deposit for a pubkey that is already a builder is queued. Input State Configured: - - Builder at index 0: withdrawable_epoch = current_epoch + 1, balance = 0 + - Existing builder pubkey + - Builder withdrawal credentials (0x03 prefix) Output State Verified: - - Slot is NOT reusable (epoch in future) - - New builder appended to registry - - Original builders unchanged + - Pending deposit added to the validator queue + - Builder unchanged (no top-up) """ - pre_builder_count = len(state.builders) - - # Advance epochs and make builder 0 NOT yet reusable (one epoch in future) + amount = spec.MIN_DEPOSIT_AMOUNT + # Deposit for a pubkey that is already a builder, with builder (0x03) credentials + builder_pubkey = state.builders[0].pubkey + withdrawal_credentials = spec.BUILDER_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 deposit_request = prepare_process_deposit_request( spec, state, - for_builder=True, - amount=spec.MIN_DEPOSIT_AMOUNT, + pubkey=builder_pubkey, + amount=amount, signed=True, - advance_epochs=1, - builder_modifications={0: {"withdrawable_epoch": "current_epoch+1", "balance": 0}}, + withdrawal_credentials=withdrawal_credentials, ) pre_state = state.copy() yield from run_deposit_request_processing(spec, state, deposit_request) - # slot_reused=False also verifies original builders are unchanged assert_process_deposit_request( spec, state, pre_state, deposit_request=deposit_request, - is_builder_deposit=True, - slot_reused=False, - expected_builder_count=pre_builder_count + 1, - expected_builder_index=pre_builder_count, + expected_pending_deposit_pubkey=deposit_request.pubkey, + expected_pending_deposit_amount=amount, ) + # The existing builder must be untouched + assert state.builders[0] == pre_state.builders[0] @with_gloas_and_later @spec_state_test -def test_process_deposit_request__no_reuse_nonzero_balance(spec, state): +def test_process_deposit_request__builder_pubkey_validator_credentials(spec, state): """ - Test slot NOT reusable when balance == 1. + Test that a deposit for a builder pubkey with validator credentials is queued. Input State Configured: - - Builder at index 0: withdrawable_epoch in past, balance = 1 (minimum non-zero) + - Existing builder pubkey + - ETH1 withdrawal credentials (0x01 prefix) Output State Verified: - - Slot is NOT reusable (non-zero balance blocks reuse) - - New builder appended to registry - - Original builders unchanged + - Pending deposit added to the validator queue + - Builder unchanged (no top-up) """ - pre_builder_count = len(state.builders) - - # Advance epochs and make builder 0 exited but with minimum non-zero balance + amount = spec.MIN_DEPOSIT_AMOUNT + builder_pubkey = state.builders[0].pubkey + withdrawal_credentials = spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 deposit_request = prepare_process_deposit_request( spec, state, - for_builder=True, - amount=spec.MIN_DEPOSIT_AMOUNT, + pubkey=builder_pubkey, + amount=amount, signed=True, - advance_epochs=1, - builder_modifications={0: {"withdrawable_epoch": "current_epoch-1", "balance": 1}}, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # slot_reused=False also verifies original builders are unchanged - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - slot_reused=False, - expected_builder_count=pre_builder_count + 1, - expected_builder_index=pre_builder_count, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__new_builder_empty_registry(spec, state): - """ - Test new builder deposit when state.builders is empty. - - Input State Configured: - - Empty builders registry - - Output State Verified: - - New builder appended as index 0 - - Builder has correct fields including withdrawable_epoch = FAR_FUTURE_EPOCH - """ - amount = spec.MIN_DEPOSIT_AMOUNT - # Clear builders registry via the helper - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=amount, signed=True, builders=[] + withdrawal_credentials=withdrawal_credentials, ) pre_state = state.copy() @@ -562,769 +128,8 @@ def test_process_deposit_request__new_builder_empty_registry(spec, state): state, pre_state, deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=1, - expected_builder_index=0, - expected_builder_balance=amount, - expected_builder_withdrawable_epoch=spec.FAR_FUTURE_EPOCH, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__builder_top_up_single_builder(spec, state): - """ - Test top-up with single builder in registry. - - Input State Configured: - - Exactly one builder in registry - - Output State Verified: - - Builder balance increased - - Builder count unchanged (still 1) - """ - # Keep only one builder - first_builder = state.builders[0] - pre_balance = first_builder.balance - amount = spec.MIN_DEPOSIT_AMOUNT - - # Top-up existing builder at index 0, keeping only one builder - deposit_request = prepare_process_deposit_request( - spec, - state, - builder_index=0, - amount=amount, - signed=True, - builders=[first_builder], - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=1, - expected_builder_balance=pre_balance + amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__builder_top_up_last_index(spec, state): - """ - Test top-up targeting last builder in registry. - - Input State Configured: - - Multiple builders in registry - - Top-up targets last builder - - Output State Verified: - - Last builder's balance increased - - Other builders unchanged (verified by assert_process_deposit_request invariant) - """ - last_index = len(state.builders) - 1 - pubkey = state.builders[last_index].pubkey - pre_balance = state.builders[last_index].balance - pre_builder_count = len(state.builders) - amount = spec.MIN_DEPOSIT_AMOUNT - - # Top-up existing builder at last index using its pubkey - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, pubkey=pubkey, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # assert_process_deposit_request verifies other builders unchanged for top-ups - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count, - expected_builder_index=last_index, - expected_builder_balance=pre_balance + amount, - ) - - -# -# Deposit routing tests -# -# These tests verify the routing logic in process_deposit_request: -# - Existing builder pubkey → builder (regardless of credentials) -# - Existing validator pubkey → validator queue (regardless of credentials) -# - New pubkey + builder credentials → new builder -# - New pubkey + validator credentials → validator queue -# - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__builder_pubkey_validator_credentials(spec, state): - """Test that existing builder pubkey with validator credentials still tops up builder.""" - builder_pubkey = state.builders[0].pubkey - amount = spec.MIN_DEPOSIT_AMOUNT - pre_balance = state.builders[0].balance - pre_builder_count = len(state.builders) - - # Create validator-style withdrawal credentials (BLS prefix) - but use builder pubkey - withdrawal_credentials = spec.BLS_WITHDRAWAL_PREFIX + spec.hash(builder_pubkey)[1:] - - # Use builder_index=0 to use existing builder's pubkey with validator credentials - deposit_request = prepare_process_deposit_request( - spec, - state, - builder_index=0, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should top up existing builder (credentials are ignored for existing builders) - # is_builder_deposit=True because the pubkey belongs to an existing builder - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count, - expected_builder_balance=pre_balance + amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__validator_pubkey_builder_credentials(spec, state): - """Test that existing validator pubkey with builder credentials goes to validator queue.""" - validator_pubkey = state.validators[0].pubkey - amount = spec.MIN_DEPOSIT_AMOUNT - - # Create builder withdrawal credentials - but use existing validator pubkey - withdrawal_credentials = spec.BUILDER_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 - - # Use validator_index=0 to use existing validator's pubkey with builder credentials - deposit_request = prepare_process_deposit_request( - spec, - state, - validator_index=0, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should route to validator queue (pubkey lookup finds validator first) - # is_builder_deposit=False because it goes to validator queue - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - expected_pending_deposit_pubkey=validator_pubkey, - expected_pending_deposit_amount=amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__validator_pubkey_validator_credentials(spec, state): - """Test that existing validator pubkey with validator credentials goes to validator queue.""" - validator_pubkey = state.validators[0].pubkey - amount = spec.MIN_DEPOSIT_AMOUNT - - # Create deposit with validator (non-builder) credentials for existing validator - deposit_request = prepare_process_deposit_request( - spec, - state, - validator_index=0, - amount=amount, - signed=True, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should route to validator queue - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - expected_pending_deposit_pubkey=validator_pubkey, - expected_pending_deposit_amount=amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__new_pubkey_validator_credentials(spec, state): - """Test that new pubkey with validator credentials goes to validator queue.""" - # Use a pubkey that doesn't exist as validator or builder (new validator index) - new_pubkey = pubkeys[len(state.validators)] - amount = spec.MIN_DEPOSIT_AMOUNT - - # Create deposit with validator (non-builder) credentials for new validator - deposit_request = prepare_process_deposit_request( - spec, - state, - amount=amount, - signed=True, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should route to validator queue (for new validator creation) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - expected_pending_deposit_pubkey=new_pubkey, - expected_pending_deposit_amount=amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__new_pubkey_eth1_credentials(spec, state): - """ - Test that new pubkey with ETH1 credentials (0x01) routes to validator queue. - - Input State Configured: - - New pubkey (not existing validator or builder) - - ETH1_ADDRESS_WITHDRAWAL_PREFIX credentials - - Output State Verified: - - No new builder created (builder count unchanged) - - Pending deposit added to validator queue - """ - new_pubkey = pubkeys[len(state.validators)] - amount = spec.MIN_DEPOSIT_AMOUNT - - # Create ETH1 withdrawal credentials (0x01 prefix) - withdrawal_credentials = ( - spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 # 20-byte eth1 address - ) - - deposit_request = prepare_process_deposit_request( - spec, - state, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - expected_pending_deposit_pubkey=new_pubkey, - expected_pending_deposit_credentials=withdrawal_credentials, - ) - - -# -# Pending validator deposit tests -# -# These tests verify that deposits with builder credentials for pubkeys -# that already have a pending deposit with a valid signature go to the -# validator queue instead of becoming builders. -# - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__new_pubkey_compounding_credentials(spec, state): - """ - Test new pubkey with compounding credentials (0x02) routes to validator queue. - - Input State Configured: - - New pubkey (not existing validator or builder) - - COMPOUNDING_WITHDRAWAL_PREFIX credentials - - Output State Verified: - - No new builder created (builder count unchanged) - - Pending deposit added to validator queue - """ - new_pubkey = pubkeys[len(state.validators)] - amount = spec.MIN_DEPOSIT_AMOUNT - - # Create compounding withdrawal credentials (0x02 prefix) - withdrawal_credentials = ( - spec.COMPOUNDING_WITHDRAWAL_PREFIX + b"\x00" * 11 + b"\x59" * 20 # 20-byte eth1 address - ) - - deposit_request = prepare_process_deposit_request( - spec, - state, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - expected_pending_deposit_pubkey=new_pubkey, + expected_pending_deposit_pubkey=deposit_request.pubkey, expected_pending_deposit_credentials=withdrawal_credentials, ) - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__routing__pending_deposit_valid_signature(spec, state): - """Test that pubkey with pending deposit with valid signature goes to validator queue.""" - # Use a pubkey that doesn't exist as validator or builder yet - new_validator_index = len(state.validators) - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add a pending deposit with a valid signature for this pubkey - pending_withdrawal_credentials = make_withdrawal_credentials( - spec, spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX, b"\xab" - ) - pending_deposit = prepare_pending_deposit( - spec, - new_validator_index, - amount, - withdrawal_credentials=pending_withdrawal_credentials, - signed=True, - ) - state.pending_deposits.append(pending_deposit) - - # Now create a deposit request with builder credentials for the same pubkey - deposit_request = prepare_deposit_request( - spec, - new_validator_index, - amount, - index=0, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\x59" - ), - signed=True, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should NOT create a new builder (pubkey is a pending validator) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - ) - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__routing__pending_deposit_invalid_signature(spec, state): - """Test that pubkey with pending deposit with invalid signature becomes builder.""" - # Use a pubkey from builder_pubkeys that doesn't exist as validator or builder yet - existing_builder_pubkeys = {builder.pubkey for builder in state.builders} - new_builder_pubkey = None - for pk in builder_pubkey_to_privkey: - if pk not in existing_builder_pubkeys: - new_builder_pubkey = pk - break - assert new_builder_pubkey is not None - privkey = builder_pubkey_to_privkey[new_builder_pubkey] - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add a pending deposit with an invalid signature (won't create a validator) - invalid_pending_deposit = spec.PendingDeposit( - pubkey=new_builder_pubkey, - amount=amount, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX, b"\xab" - ), - signature=spec.BLSSignature(), # Invalid empty signature - slot=spec.GENESIS_SLOT, - ) - state.pending_deposits.append(invalid_pending_deposit) - - # Now create a deposit request with builder credentials for the same pubkey - deposit_request = prepare_deposit_request( - spec, - 0, - amount, - index=0, - pubkey=new_builder_pubkey, - privkey=privkey, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\x59" - ), - signed=True, - ) - - pre_builder_count = len(state.builders) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # SHOULD create a new builder (pending deposit has invalid sig, so is_pending_validator is False) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count + 1, - expected_builder_balance=amount, - expected_execution_address=spec.ExecutionAddress( - deposit_request.withdrawal_credentials[12:] - ), - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__nonstandard_credential_padding(spec, state): - """ - Test builder deposit with non-zero bytes in credentials[1:12]. - - The Builder container extracts execution_address from credentials[12:]. - This test verifies that non-standard padding (non-zero bytes in [1:12]) - still results in correct address extraction. - - Input State Configured: - - New builder pubkey - - Builder credentials with non-zero padding in bytes 1-11 - - Output State Verified: - - New builder created - - execution_address extracted from credentials[12:] - - Non-zero padding bytes in [1:12] are ignored - """ - amount = spec.MIN_DEPOSIT_AMOUNT - pre_builder_count = len(state.builders) - - # Create credentials with non-zero padding bytes in [1:12] - execution_address = b"\xab" * 20 # 20-byte execution address - nonstandard_padding = b"\xff" * 11 # Non-zero bytes instead of standard 0x00 - - withdrawal_credentials = ( - spec.BUILDER_WITHDRAWAL_PREFIX - + nonstandard_padding # Non-standard: typically b"\x00" * 11 - + execution_address - ) - - deposit_request = prepare_process_deposit_request( - spec, - state, - for_builder=True, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_count=pre_builder_count + 1, - expected_builder_balance=amount, - expected_execution_address=spec.ExecutionAddress(execution_address), - ) - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__routing__pending_deposits_invalid_then_valid(spec, state): - """Test that pubkey with invalid pending deposits followed by valid one goes to validator queue.""" - # Use a pubkey that doesn't exist as validator or builder yet - new_validator_index = len(state.validators) - new_pubkey = pubkeys[new_validator_index] - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add multiple pending deposits with invalid signatures first - for _ in range(3): - invalid_pending_deposit = spec.PendingDeposit( - pubkey=new_pubkey, - amount=amount, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX, b"\xab" - ), - signature=spec.BLSSignature(), # Invalid empty signature - slot=spec.GENESIS_SLOT, - ) - state.pending_deposits.append(invalid_pending_deposit) - - # Add a valid pending deposit after the invalid ones - valid_withdrawal_credentials = make_withdrawal_credentials( - spec, spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX, b"\xcd" - ) - valid_pending_deposit = prepare_pending_deposit( - spec, - new_validator_index, - amount, - withdrawal_credentials=valid_withdrawal_credentials, - signed=True, - ) - state.pending_deposits.append(valid_pending_deposit) - - # Now create a deposit request with builder credentials for the same pubkey - deposit_request = prepare_deposit_request( - spec, - new_validator_index, - amount, - index=0, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\x59" - ), - signed=True, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should NOT create a new builder (there's a valid pending deposit in the queue) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - ) - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_process_deposit_request__routing__pending_deposit_builder_credentials(spec, state): - """Test that pubkey with pending deposit with builder credentials goes to validator queue.""" - # Use a pubkey that doesn't exist as validator or builder yet - new_validator_index = len(state.validators) - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add a pending deposit with builder credentials and a valid signature - pending_withdrawal_credentials = make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\xab" - ) - pending_deposit = prepare_pending_deposit( - spec, - new_validator_index, - amount, - withdrawal_credentials=pending_withdrawal_credentials, - signed=True, - ) - state.pending_deposits.append(pending_deposit) - - # Now create another deposit request with builder credentials for the same pubkey - deposit_request = prepare_deposit_request( - spec, - new_validator_index, - amount, - index=0, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\x59" - ), - signed=True, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should NOT create a new builder (pubkey has valid pending deposit) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__pending_deposit_different_pubkeys(spec, state): - """ - Test is_pending_validator skips non-matching pubkeys before finding matching one. - - Exercises the `continue` branch in is_pending_validator when pending deposits - with different pubkeys exist before the matching deposit. - - Input State Configured: - - Pending deposits for two unrelated validator pubkeys - - Pending deposit for the target pubkey - - Builder-credential deposit request for the target pubkey - - Output State Verified: - - Deposit routed to validator queue (not builder) - - is_pending_validator returns True after skipping non-matching deposits - """ - new_validator_index = len(state.validators) - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add pending deposits with different pubkeys (these will be skipped via `continue`) - for i in range(2): - unrelated_index = new_validator_index + 1 + i - unrelated_pending = prepare_pending_deposit( - spec, - unrelated_index, - amount, - signed=True, - ) - state.pending_deposits.append(unrelated_pending) - - # Add a pending deposit with the target pubkey (this will match) - target_pending = prepare_pending_deposit( - spec, - new_validator_index, - amount, - signed=True, - ) - state.pending_deposits.append(target_pending) - - # Create a deposit request with builder credentials for the target pubkey - deposit_request = prepare_deposit_request( - spec, - new_validator_index, - amount, - index=0, - withdrawal_credentials=make_withdrawal_credentials( - spec, spec.BUILDER_WITHDRAWAL_PREFIX, b"\x59" - ), - signed=True, - ) - - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should NOT create a new builder (is_pending_validator finds matching deposit) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=False, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__empty_pending_deposits(spec, state): - """ - Test is_pending_validator with empty pending deposit queue. - - When pending_deposits is empty, is_pending_validator returns False - immediately and the deposit is routed to the builder path. - - Input State Configured: - - Empty pending_deposits queue - - Builder-credential deposit request for a new pubkey - - Output State Verified: - - Deposit routed to builder path (new builder created) - """ - amount = spec.MIN_DEPOSIT_AMOUNT - - # Ensure pending deposits is empty - state.pending_deposits = [] - - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should create a new builder (no pending deposits, is_pending_validator returns False) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_balance=amount, - ) - - -@with_gloas_and_later -@spec_state_test -def test_process_deposit_request__routing__pending_deposits_all_different_pubkeys(spec, state): - """ - Test is_pending_validator when all pending deposits have different pubkeys. - - When no pending deposit matches the target pubkey, is_pending_validator - hits `continue` for every deposit and returns False. - - Input State Configured: - - Pending deposits for two unrelated validator pubkeys - - Builder-credential deposit request for a pubkey not in the queue - - Output State Verified: - - Deposit routed to builder path (new builder created) - """ - new_validator_index = len(state.validators) - amount = spec.MIN_DEPOSIT_AMOUNT - - # Add pending deposits with unrelated pubkeys - for i in range(2): - unrelated_index = new_validator_index + 1 + i - unrelated_pending = prepare_pending_deposit( - spec, - unrelated_index, - amount, - signed=True, - ) - state.pending_deposits.append(unrelated_pending) - - # Create a builder deposit request for a pubkey NOT in the pending queue - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=amount, signed=True - ) - pre_state = state.copy() - - yield from run_deposit_request_processing(spec, state, deposit_request) - - # Should create a new builder (no matching pubkey, is_pending_validator returns False) - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - expected_builder_balance=amount, - ) + # The existing builder must be untouched + assert state.builders[0] == pre_state.builders[0] diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_parent_execution_payload.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_parent_execution_payload.py index 4c1bcd9dbc1..094012036c7 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_parent_execution_payload.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_parent_execution_payload.py @@ -12,7 +12,7 @@ get_non_empty_execution_requests, ) from eth_consensus_specs.test.helpers.keys import pubkeys -from tests.infra.helpers.deposit_requests import prepare_process_deposit_request +from tests.infra.helpers.builder_deposit_requests import prepare_process_builder_deposit_request from tests.infra.helpers.withdrawals import set_parent_block_full @@ -310,17 +310,16 @@ def test_process_parent_execution_payload__full_parent_with_execution_requests(s @with_gloas_and_later @spec_state_test -def test_process_parent_execution_payload__builder_deposit_after_pending_validator(spec, state): +def test_process_parent_execution_payload__builder_credential_deposits_queued(spec, state): """ - Test that a builder deposit cannot claim a pubkey that is already a pending validator - earlier in the same parent execution requests batch. + Test that deposit requests are queued as pending deposits regardless of + their withdrawal credentials. Deposit requests never create builders. """ new_validator_index = len(state.validators) new_validator_pubkey = pubkeys[new_validator_index] amount = spec.MIN_DEPOSIT_AMOUNT - # First deposit: regular validator credentials with valid signature. - # Since no validator/builder/pending deposit exists for this pubkey, it is queued as a pending validator. + # First deposit: regular validator credentials with valid signature deposit_request_1 = prepare_deposit_request( spec, new_validator_index, @@ -333,10 +332,7 @@ def test_process_parent_execution_payload__builder_deposit_after_pending_validat signed=True, ) - # Second deposit: builder credentials for the same pubkey. - # ``is_pending_validator`` must see the first deposit (just queued) and route this one - # to the pending queue instead of the builder registry, preventing a builder from - # claiming a pubkey already in the validator queue. + # Second deposit: builder credentials for the same pubkey, also queued deposit_request_2 = prepare_deposit_request( spec, new_validator_index, @@ -481,7 +477,7 @@ def test_process_parent_execution_payload__new_builder_does_not_reuse_topped_up_ top_up_amount = spec.MIN_DEPOSIT_AMOUNT new_builder_amount = spec.MIN_DEPOSIT_AMOUNT - deposit_request_1 = prepare_process_deposit_request( + builder_deposit_request_1 = prepare_process_builder_deposit_request( spec, state, builder_index=0, @@ -489,22 +485,17 @@ def test_process_parent_execution_payload__new_builder_does_not_reuse_topped_up_ amount=top_up_amount, signed=True, ) - deposit_request_2 = prepare_process_deposit_request( + builder_deposit_request_2 = prepare_process_builder_deposit_request( spec, state, - for_builder=True, amount=new_builder_amount, signed=True, ) requests = spec.ExecutionRequests( - deposits=spec.List[spec.DepositRequest, spec.MAX_DEPOSIT_REQUESTS_PER_PAYLOAD]( - [deposit_request_1, deposit_request_2] - ), - withdrawals=spec.List[spec.WithdrawalRequest, spec.MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD](), - consolidations=spec.List[ - spec.ConsolidationRequest, spec.MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD - ](), + builder_deposits=spec.List[ + spec.BuilderDepositRequest, spec.MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD + ]([builder_deposit_request_1, builder_deposit_request_2]), ) _commit_parent_requests(spec, state, requests) @@ -524,10 +515,54 @@ def test_process_parent_execution_payload__new_builder_does_not_reuse_topped_up_ new_builder_index = None for i, builder in enumerate(state.builders): - if builder.pubkey == deposit_request_2.pubkey: + if builder.pubkey == builder_deposit_request_2.pubkey: new_builder_index = i break assert new_builder_index is not None assert new_builder_index != 0 assert state.builders[new_builder_index].balance == new_builder_amount + + +@with_gloas_and_later +@spec_state_test +def test_process_parent_execution_payload__builder_exit_request(spec, state): + """ + Test that a builder exit request in the parent's execution requests exits an + active builder authorized by its execution address. + """ + builder_index = 0 + + # Finalize the builder's deposit epoch so that it is active + state.finalized_checkpoint.epoch = state.builders[builder_index].deposit_epoch + 1 + assert spec.is_active_builder(state, builder_index) + assert spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) == 0 + + builder = state.builders[builder_index] + requests = spec.ExecutionRequests( + builder_exits=spec.List[ + spec.BuilderExitRequest, spec.MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD + ]( + [ + spec.BuilderExitRequest( + source_address=builder.execution_address, + pubkey=builder.pubkey, + ) + ] + ), + ) + + _commit_parent_requests(spec, state, requests) + + block = build_empty_block_for_next_slot(spec, state) + block.body.parent_execution_requests = requests + + spec.process_slots(state, block.slot) + current_epoch = spec.get_current_epoch(state) + + yield from run_parent_execution_payload_processing(spec, state, block) + + # The builder exit was initiated + assert not spec.is_active_builder(state, builder_index) + expected_withdrawable = current_epoch + spec.config.MIN_BUILDER_WITHDRAWABILITY_DELAY + assert state.builders[builder_index].withdrawable_epoch == expected_withdrawable diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_voluntary_exit.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_voluntary_exit.py index 2375644c9b6..b898005f700 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_voluntary_exit.py +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/block_processing/test_process_voluntary_exit.py @@ -1,5 +1,4 @@ from eth_consensus_specs.test.context import ( - always_bls, expect_assertion_error, spec_state_test, with_gloas_and_later, @@ -9,161 +8,25 @@ from eth_consensus_specs.test.helpers.voluntary_exits import sign_voluntary_exit -def advance_past_finalization(spec, state): - """Advance slots and finalize so that genesis-epoch builders become active.""" - epoch = spec.get_current_epoch(state) - next_slots(spec, state, spec.SLOTS_PER_EPOCH * 3) - state.finalized_checkpoint.epoch = epoch + 1 - - -@with_gloas_and_later -@spec_state_test -def test_builder_voluntary_exit__success(spec, state): - """Test successful builder voluntary exit with no pending balance.""" - builder_index = 0 - pubkey = state.builders[builder_index].pubkey - privkey = builder_pubkey_to_privkey[pubkey] - - advance_past_finalization(spec, state) - assert spec.is_active_builder(state, builder_index) - - assert spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) == 0 - - current_epoch = spec.get_current_epoch(state) - - validator_index = spec.convert_builder_index_to_validator_index(builder_index) - voluntary_exit = spec.VoluntaryExit( - epoch=current_epoch, - validator_index=validator_index, - ) - signed_voluntary_exit = sign_voluntary_exit(spec, state, voluntary_exit, privkey) - - yield "pre", state - yield "voluntary_exit", signed_voluntary_exit - spec.process_voluntary_exit(state, signed_voluntary_exit) - yield "post", state - - assert not spec.is_active_builder(state, builder_index) - - expected_withdrawable = current_epoch + spec.config.MIN_BUILDER_WITHDRAWABILITY_DELAY - assert state.builders[builder_index].withdrawable_epoch == expected_withdrawable - - -@with_gloas_and_later -@spec_state_test -def test_builder_voluntary_exit__invalid__inactive_deposit_epoch(spec, state): - """Test that inactive builders cannot exit.""" - builder_index = 0 - pubkey = state.builders[builder_index].pubkey - privkey = builder_pubkey_to_privkey[pubkey] - - # Set builder's deposit epoch to a non-finalized epoch - state.builders[builder_index].deposit_epoch = spec.Epoch(1) - - advance_past_finalization(spec, state) - assert state.finalized_checkpoint.epoch == state.builders[builder_index].deposit_epoch - assert not spec.is_active_builder(state, builder_index) - - validator_index = spec.convert_builder_index_to_validator_index(builder_index) - voluntary_exit = spec.VoluntaryExit( - epoch=spec.get_current_epoch(state), - validator_index=validator_index, - ) - signed_voluntary_exit = sign_voluntary_exit(spec, state, voluntary_exit, privkey) - - yield "pre", state - yield "voluntary_exit", signed_voluntary_exit - expect_assertion_error(lambda: spec.process_voluntary_exit(state, signed_voluntary_exit)) - yield "post", None - - -@with_gloas_and_later -@spec_state_test -def test_builder_voluntary_exit__invalid__inactive_already_exited(spec, state): - """Test that already-exited builders cannot exit again.""" - builder_index = 0 - pubkey = state.builders[builder_index].pubkey - privkey = builder_pubkey_to_privkey[pubkey] - - # Set builder's withdrawable epoch which indicates it has initiated an exit - state.builders[builder_index].withdrawable_epoch = spec.get_current_epoch(state) + 10 - - advance_past_finalization(spec, state) - assert not spec.is_active_builder(state, builder_index) - - validator_index = spec.convert_builder_index_to_validator_index(builder_index) - voluntary_exit = spec.VoluntaryExit( - epoch=spec.get_current_epoch(state), - validator_index=validator_index, - ) - signed_voluntary_exit = sign_voluntary_exit(spec, state, voluntary_exit, privkey) - - yield "pre", state - yield "voluntary_exit", signed_voluntary_exit - expect_assertion_error(lambda: spec.process_voluntary_exit(state, signed_voluntary_exit)) - yield "post", None - - @with_gloas_and_later @spec_state_test -def test_builder_voluntary_exit__invalid__pending_withdrawal(spec, state): - """Test that builder cannot exit while having pending withdrawal.""" - builder_index = 0 - pubkey = state.builders[builder_index].pubkey - privkey = builder_pubkey_to_privkey[pubkey] +def test_voluntary_exit__invalid__builder_index(spec, state): + """ + Test that a voluntary exit targeting a builder index is invalid. - advance_past_finalization(spec, state) - assert spec.is_active_builder(state, builder_index) - - # Add pending withdrawal for this builder - withdrawal_amount = spec.MIN_ACTIVATION_BALANCE - withdrawal = spec.BuilderPendingWithdrawal( - fee_recipient=spec.ExecutionAddress(b"\x70" * 20), - amount=withdrawal_amount, - builder_index=builder_index, - ) - state.builder_pending_withdrawals.append(withdrawal) - pending_balance = spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) - assert pending_balance == withdrawal_amount - - validator_index = spec.convert_builder_index_to_validator_index(builder_index) - voluntary_exit = spec.VoluntaryExit( - epoch=spec.get_current_epoch(state), - validator_index=validator_index, - ) - signed_voluntary_exit = sign_voluntary_exit(spec, state, voluntary_exit, privkey) - - yield "pre", state - yield "voluntary_exit", signed_voluntary_exit - expect_assertion_error(lambda: spec.process_voluntary_exit(state, signed_voluntary_exit)) - yield "post", None - - -@with_gloas_and_later -@spec_state_test -def test_builder_voluntary_exit__invalid__pending_payment(spec, state): - """Test that builder cannot exit while having pending payment.""" + The voluntary exit operation is validator-only. Builders exit via + builder exit requests. + """ builder_index = 0 pubkey = state.builders[builder_index].pubkey privkey = builder_pubkey_to_privkey[pubkey] - advance_past_finalization(spec, state) + # Advance slots and finalize so that genesis-epoch builders become active + epoch = spec.get_current_epoch(state) + next_slots(spec, state, spec.SLOTS_PER_EPOCH * 3) + state.finalized_checkpoint.epoch = epoch + 1 assert spec.is_active_builder(state, builder_index) - # Add pending payment for this builder - payment_amount = spec.MIN_ACTIVATION_BALANCE - payment = spec.BuilderPendingPayment( - weight=spec.get_builder_payment_quorum_threshold(state) + 1, - withdrawal=spec.BuilderPendingWithdrawal( - fee_recipient=spec.ExecutionAddress(b"\x60" * 20), - amount=payment_amount, - builder_index=builder_index, - ), - ) - state.builder_pending_payments[0] = payment - pending_balance = spec.get_pending_balance_to_withdraw_for_builder(state, builder_index) - assert pending_balance == payment_amount - validator_index = spec.convert_builder_index_to_validator_index(builder_index) voluntary_exit = spec.VoluntaryExit( epoch=spec.get_current_epoch(state), @@ -175,27 +38,3 @@ def test_builder_voluntary_exit__invalid__pending_payment(spec, state): yield "voluntary_exit", signed_voluntary_exit expect_assertion_error(lambda: spec.process_voluntary_exit(state, signed_voluntary_exit)) yield "post", None - - -@with_gloas_and_later -@spec_state_test -@always_bls -def test_builder_voluntary_exit__invalid__bad_signature(spec, state): - """Test builder voluntary exit with invalid signature.""" - builder_index = 0 - - advance_past_finalization(spec, state) - assert spec.is_active_builder(state, builder_index) - - validator_index = spec.convert_builder_index_to_validator_index(builder_index) - voluntary_exit = spec.VoluntaryExit( - epoch=spec.get_current_epoch(state), - validator_index=validator_index, - ) - # Use the wrong privkey so the signature is invalid - signed_voluntary_exit = sign_voluntary_exit(spec, state, voluntary_exit, 12345) - - yield "pre", state - yield "voluntary_exit", signed_voluntary_exit - expect_assertion_error(lambda: spec.process_voluntary_exit(state, signed_voluntary_exit)) - yield "post", None diff --git a/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_pending_deposits.py b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_pending_deposits.py new file mode 100644 index 00000000000..a4f0a010601 --- /dev/null +++ b/tests/core/pyspec/eth_consensus_specs/test/gloas/epoch_processing/test_process_pending_deposits.py @@ -0,0 +1,43 @@ +from eth_consensus_specs.test.context import always_bls, spec_state_test, with_gloas_and_later +from eth_consensus_specs.test.helpers.deposits import prepare_pending_deposit +from eth_consensus_specs.test.helpers.epoch_processing import run_epoch_processing_with +from eth_consensus_specs.test.helpers.keys import privkeys +from eth_consensus_specs.utils import bls + + +@with_gloas_and_later +@spec_state_test +@always_bls +def test_process_pending_deposits__builder_deposit_domain(spec, state): + """ + Test that a pending deposit signed under DOMAIN_BUILDER_DEPOSIT is dropped. + + A signature over the same DepositMessage under DOMAIN_BUILDER_DEPOSIT is a + valid builder deposit signature. It must not be accepted here, otherwise a + builder deposit could be replayed to the validator deposit contract to + register its pubkey as a validator. + """ + # A new validator, pubkey doesn't exist in the state + validator_index = len(state.validators) + amount = spec.MIN_ACTIVATION_BALANCE + pre_validator_count = len(state.validators) + + deposit = prepare_pending_deposit(spec, validator_index, amount, signed=False) + + # Sign over the builder deposit domain instead of DOMAIN_DEPOSIT + deposit_message = spec.DepositMessage( + pubkey=deposit.pubkey, + withdrawal_credentials=deposit.withdrawal_credentials, + amount=deposit.amount, + ) + domain = spec.compute_domain(spec.DOMAIN_BUILDER_DEPOSIT) + signing_root = spec.compute_signing_root(deposit_message, domain) + deposit.signature = bls.Sign(privkeys[validator_index], signing_root) + + state.pending_deposits.append(deposit) + + yield from run_epoch_processing_with(spec, state, "process_pending_deposits") + + # The deposit was dropped: no validator created and no balance applied + assert len(state.validators) == pre_validator_count + assert state.pending_deposits == [] diff --git a/tests/core/pyspec/eth_consensus_specs/test/helpers/deposits.py b/tests/core/pyspec/eth_consensus_specs/test/helpers/deposits.py index d0bba8b2716..84785e4f6ea 100644 --- a/tests/core/pyspec/eth_consensus_specs/test/helpers/deposits.py +++ b/tests/core/pyspec/eth_consensus_specs/test/helpers/deposits.py @@ -236,16 +236,31 @@ def prepare_deposit_request( ) +def sign_builder_deposit_request(spec, request, privkey): + """ + Sign the ``DepositMessage`` corresponding to the given request. + """ + deposit_message = spec.DepositMessage( + pubkey=request.pubkey, + withdrawal_credentials=request.withdrawal_credentials, + amount=request.amount, + ) + domain = spec.compute_domain(spec.DOMAIN_BUILDER_DEPOSIT) + signing_root = spec.compute_signing_root(deposit_message, domain) + return bls.Sign(privkey, signing_root) + + def prepare_builder_deposit_request( spec, state, amount, pubkey=None, + privkey=None, withdrawal_credentials=None, signed=False, ): """ - Create a deposit request for a builder, depositing the given amount. + Create a builder deposit request, depositing the given amount. If pubkey is None, finds an unused keypair from builder_pubkeys for a new builder. If pubkey is provided, creates a top-up deposit for an existing builder. @@ -260,27 +275,22 @@ def prepare_builder_deposit_request( if pubkey is None: raise ValueError("No unused builder pubkeys available") - # Look up privkey from the pubkey->privkey map - privkey = builder_pubkey_to_privkey[pubkey] + if privkey is None: + # Look up privkey from the pubkey->privkey map + privkey = builder_pubkey_to_privkey[pubkey] - # Use builder withdrawal prefix (0x03) if withdrawal_credentials is None: - withdrawal_credentials = ( - spec.BUILDER_WITHDRAWAL_PREFIX - + b"\x00" * 11 - + spec.hash(pubkey)[12:] # a 20-byte eth1 address derived from pubkey - ) + # Version zero followed by an eth1 address derived from the pubkey + withdrawal_credentials = b"\x00" * 12 + spec.hash(pubkey)[12:] - deposit_data = build_deposit_data( - spec, pubkey, privkey, amount, withdrawal_credentials, signed=signed - ) - return spec.DepositRequest( - pubkey=deposit_data.pubkey, - withdrawal_credentials=deposit_data.withdrawal_credentials, - amount=deposit_data.amount, - signature=deposit_data.signature, - index=spec.uint64(0), + request = spec.BuilderDepositRequest( + pubkey=pubkey, + withdrawal_credentials=withdrawal_credentials, + amount=amount, ) + if signed: + request.signature = sign_builder_deposit_request(spec, request, privkey) + return request def prepare_pending_deposit( diff --git a/tests/formats/operations/README.md b/tests/formats/operations/README.md index e783d32ec1f..1b6e413d2f7 100644 --- a/tests/formats/operations/README.md +++ b/tests/formats/operations/README.md @@ -34,24 +34,26 @@ the block-transition. Operations: -| *`operation-name`* | *`operation-object`* | *`input name`* | *`processing call`* | -| -------------------------- | ---------------------------- | ----------------------- | ------------------------------------------------------------------------------ | -| `attestation` | `Attestation` | `attestation` | `process_attestation(state, attestation)` | -| `attester_slashing` | `AttesterSlashing` | `attester_slashing` | `process_attester_slashing(state, attester_slashing)` | -| `block_header` | `BeaconBlock` | **`block`** | `process_block_header(state, block)` | -| `deposit` | `Deposit` | `deposit` | `process_deposit(state, deposit)` (removed in Fulu) | -| `proposer_slashing` | `ProposerSlashing` | `proposer_slashing` | `process_proposer_slashing(state, proposer_slashing)` | -| `voluntary_exit` | `SignedVoluntaryExit` | `voluntary_exit` | `process_voluntary_exit(state, voluntary_exit)` | -| `sync_aggregate` | `SyncAggregate` | `sync_aggregate` | `process_sync_aggregate(state, sync_aggregate)` (new in Altair) | -| `execution_payload` | `BeaconBlockBody` | **`body`** | `process_execution_payload(state, body)` (new in Bellatrix, removed in Gloas) | -| `withdrawals` | `ExecutionPayload` | `execution_payload` | `process_withdrawals(state, execution_payload)` (new in Capella) | -| `bls_to_execution_change` | `SignedBLSToExecutionChange` | `address_change` | `process_bls_to_execution_change(state, address_change)` (new in Capella) | -| `deposit_request` | `DepositRequest` | `deposit_request` | `process_deposit_request(state, deposit_request)` (new in Electra) | -| `withdrawal_request` | `WithdrawalRequest` | `withdrawal_request` | `process_withdrawal_request(state, withdrawal_request)` (new in Electra) | -| `consolidation_request` | `ConsolidationRequest` | `consolidation_request` | `process_consolidation_request(state, consolidation_request)` (new in Electra) | -| `execution_payload_bid` | `SignedExecutionPayloadBid` | `execution_payload_bid` | `process_execution_payload_bid(state, execution_payload_bid)` (new in Gloas) | -| `parent_execution_payload` | `BeaconBlock` | **`block`** | `process_parent_execution_payload(state, block)` (new in Gloas) | -| `payload_attestation` | `PayloadAttestation` | `payload_attestation` | `process_payload_attestation(state, payload_attestation)` (new in Gloas) | +| *`operation-name`* | *`operation-object`* | *`input name`* | *`processing call`* | +| -------------------------- | ---------------------------- | ------------------------- | -------------------------------------------------------------------------------- | +| `attestation` | `Attestation` | `attestation` | `process_attestation(state, attestation)` | +| `attester_slashing` | `AttesterSlashing` | `attester_slashing` | `process_attester_slashing(state, attester_slashing)` | +| `block_header` | `BeaconBlock` | **`block`** | `process_block_header(state, block)` | +| `deposit` | `Deposit` | `deposit` | `process_deposit(state, deposit)` (removed in Fulu) | +| `proposer_slashing` | `ProposerSlashing` | `proposer_slashing` | `process_proposer_slashing(state, proposer_slashing)` | +| `voluntary_exit` | `SignedVoluntaryExit` | `voluntary_exit` | `process_voluntary_exit(state, voluntary_exit)` | +| `sync_aggregate` | `SyncAggregate` | `sync_aggregate` | `process_sync_aggregate(state, sync_aggregate)` (new in Altair) | +| `execution_payload` | `BeaconBlockBody` | **`body`** | `process_execution_payload(state, body)` (new in Bellatrix, removed in Gloas) | +| `withdrawals` | `ExecutionPayload` | `execution_payload` | `process_withdrawals(state, execution_payload)` (new in Capella) | +| `bls_to_execution_change` | `SignedBLSToExecutionChange` | `address_change` | `process_bls_to_execution_change(state, address_change)` (new in Capella) | +| `deposit_request` | `DepositRequest` | `deposit_request` | `process_deposit_request(state, deposit_request)` (new in Electra) | +| `withdrawal_request` | `WithdrawalRequest` | `withdrawal_request` | `process_withdrawal_request(state, withdrawal_request)` (new in Electra) | +| `consolidation_request` | `ConsolidationRequest` | `consolidation_request` | `process_consolidation_request(state, consolidation_request)` (new in Electra) | +| `execution_payload_bid` | `SignedExecutionPayloadBid` | `execution_payload_bid` | `process_execution_payload_bid(state, execution_payload_bid)` (new in Gloas) | +| `parent_execution_payload` | `BeaconBlock` | **`block`** | `process_parent_execution_payload(state, block)` (new in Gloas) | +| `payload_attestation` | `PayloadAttestation` | `payload_attestation` | `process_payload_attestation(state, payload_attestation)` (new in Gloas) | +| `builder_deposit_request` | `BuilderDepositRequest` | `builder_deposit_request` | `process_builder_deposit_request(state, builder_deposit_request)` (new in Gloas) | +| `builder_exit_request` | `BuilderExitRequest` | `builder_exit_request` | `process_builder_exit_request(state, builder_exit_request)` (new in Gloas) | Note that `block_header` is not strictly an operation (and is a full `Block`), but processed in the same manner, and hence included here. diff --git a/tests/infra/helpers/builder_deposit_requests.py b/tests/infra/helpers/builder_deposit_requests.py new file mode 100644 index 00000000000..85d3d8bf423 --- /dev/null +++ b/tests/infra/helpers/builder_deposit_requests.py @@ -0,0 +1,263 @@ +from eth_consensus_specs.test.helpers.deposits import sign_builder_deposit_request +from eth_consensus_specs.test.helpers.keys import builder_pubkey_to_privkey, builder_pubkeys +from eth_consensus_specs.test.helpers.state import next_epoch + + +def run_builder_deposit_request_processing(spec, state, builder_deposit_request): + """ + Run process_builder_deposit_request, yielding pre/post states for test vectors. + + The function never raises. Requests that fail a precondition are consumed + without changing the state. + """ + yield "pre", state + yield "builder_deposit_request", builder_deposit_request + + spec.process_builder_deposit_request(state, builder_deposit_request) + yield "post", state + + +def prepare_process_builder_deposit_request( + spec, + state, + advance_epochs=None, + builder_index=None, + pubkey=None, + withdrawal_credentials=None, + amount=None, + signed=False, + builders=None, + builder_modifications=None, +): + """ + Prepare a builder deposit request operation with configurable parameters. + + This helper creates a BuilderDepositRequest object and optionally modifies + state fields related to builder deposit request processing. + + Args: + spec: The spec object. + state: The beacon state to modify. + advance_epochs: Number of epochs to advance before setup. + builder_index: Index for builder pubkey lookup. Default: len(state.builders) + (a new builder). + pubkey: Explicit BLSPubkey. Default: derived from builder_index. + withdrawal_credentials: Explicit Bytes32 credentials. Default: version zero + followed by an eth1 address derived from the pubkey. + amount: Deposit amount in Gwei. Default: MIN_ACTIVATION_BALANCE. + signed: If True, sign with a valid builder deposit signature. + builders: Override state.builders list entirely. Use [] for empty registry. + builder_modifications: Dict mapping builder index to dict of field modifications. + Supported fields: "withdrawable_epoch", "balance". + Example: {0: {"withdrawable_epoch": 5, "balance": 0}} sets builder[0] + to withdrawable at epoch 5 with zero balance. + Use "current_epoch" as a special value to set to current epoch. + Use "current_epoch-1" or "current_epoch+1" for relative epochs. + + Returns: + BuilderDepositRequest: The builder deposit request. + """ + # Phase 1: Advance epochs if requested (before setup) + if advance_epochs is not None: + for _ in range(advance_epochs): + next_epoch(spec, state) + + # Phase 2: Derive effective values + index = builder_index if builder_index is not None else len(state.builders) + effective_pubkey = pubkey if pubkey is not None else builder_pubkeys[index] + effective_privkey = builder_pubkey_to_privkey[effective_pubkey] + effective_amount = amount if amount is not None else spec.MIN_ACTIVATION_BALANCE + if withdrawal_credentials is not None: + effective_withdrawal_credentials = withdrawal_credentials + else: + # Version zero followed by an eth1 address derived from the pubkey + effective_withdrawal_credentials = b"\x00" * 12 + spec.hash(effective_pubkey)[12:] + + # Phase 3: Apply state overrides (before creating request) + if builders is not None: + state.builders = builders + + if builder_modifications is not None: + current_epoch = spec.get_current_epoch(state) + for idx, mods in builder_modifications.items(): + if "withdrawable_epoch" in mods: + epoch_value = mods["withdrawable_epoch"] + # Support special string values for relative epochs + if epoch_value == "current_epoch": + epoch_value = current_epoch + elif epoch_value == "current_epoch-1": + epoch_value = current_epoch - 1 + elif epoch_value == "current_epoch+1": + epoch_value = current_epoch + 1 + state.builders[idx].withdrawable_epoch = epoch_value + if "balance" in mods: + state.builders[idx].balance = spec.Gwei(mods["balance"]) + + # Phase 4: Build the request and optionally sign + request = spec.BuilderDepositRequest( + pubkey=effective_pubkey, + withdrawal_credentials=effective_withdrawal_credentials, + amount=effective_amount, + ) + if signed: + request.signature = sign_builder_deposit_request(spec, request, effective_privkey) + + return request + + +def assert_process_builder_deposit_request( + spec, + state, + pre_state, + builder_deposit_request, + state_unchanged=False, + expected_builder_balance=None, + expected_builder_balance_delta=None, + expected_builder_count=None, + expected_builder_index=None, + expected_execution_address=None, + expected_builder_withdrawable_epoch=None, + slot_reused=None, +): + """ + Assert expected outcomes from process_builder_deposit_request. + + TEST-SPECIFIC CHECKS (controlled by parameters): + - expected_builder_balance: Exact builder balance check + - expected_builder_balance_delta: Builder balance change check + - expected_builder_count: Exact builder count check + - expected_builder_index: Verify builder at specific index + - expected_execution_address: Verify builder's execution_address + - expected_builder_withdrawable_epoch: Expected withdrawable_epoch of the builder + - slot_reused: True = count same (slot reused), False = count +1 (new slot). + When False, also verifies original builders are unchanged. + + Args: + spec: The spec module for the fork being tested + state: State after the builder deposit request was processed + pre_state: State before the builder deposit request was processed + builder_deposit_request: The request that was processed + state_unchanged: If True, asserts state equals pre_state + """ + if state_unchanged: + assert state == pre_state, ( + "Expected state to be unchanged after rejected builder deposit request" + ) + return + + # Find the builder by pubkey + builder_index = None + for i, builder in enumerate(state.builders): + if builder.pubkey == builder_deposit_request.pubkey: + builder_index = i + break + + assert builder_index is not None, ( + f"Builder with pubkey {builder_deposit_request.pubkey[:8]}... should exist after deposit" + ) + + # Check if this was a new builder or top-up + pre_builder_index = None + for i, builder in enumerate(pre_state.builders): + if builder.pubkey == builder_deposit_request.pubkey: + pre_builder_index = i + break + + if pre_builder_index is None: + # New builder was created (could be appended or reused slot) + # The count either increases by 1 (append) or stays the same (slot reuse) + # If slot_reused is specified, that takes precedence for the check + if slot_reused is None: + # Allow either case (append or reuse) if not explicitly specified + assert len(state.builders) >= len(pre_state.builders), ( + "Builder count should not decrease for new builder deposit" + ) + else: + # Top-up of existing builder + assert len(state.builders) == len(pre_state.builders), ( + "Builder count should not change for top-up deposit" + ) + # Balance should increase + pre_balance = pre_state.builders[pre_builder_index].balance + post_balance = state.builders[builder_index].balance + assert post_balance == pre_balance + builder_deposit_request.amount, ( + f"Builder balance should increase by deposit amount: " + f"pre={pre_balance}, post={post_balance}, amount={builder_deposit_request.amount}" + ) + # All other builders should remain unchanged + for i in range(len(pre_state.builders)): + if i != pre_builder_index: + assert state.builders[i] == pre_state.builders[i], ( + f"Builder at index {i} should be unchanged during top-up" + ) + + # Test-specific checks + if expected_builder_balance is not None: + assert state.builders[builder_index].balance == expected_builder_balance + + if expected_builder_balance_delta is not None: + if pre_builder_index is not None: + pre_balance = pre_state.builders[pre_builder_index].balance + else: + pre_balance = 0 + assert state.builders[builder_index].balance == pre_balance + expected_builder_balance_delta + + if expected_builder_count is not None: + assert len(state.builders) == expected_builder_count, ( + f"expected_builder_count: expected={expected_builder_count}, got={len(state.builders)}" + ) + + if expected_builder_index is not None: + assert builder_index == expected_builder_index, ( + f"expected_builder_index: expected={expected_builder_index}, got={builder_index}" + ) + + if expected_execution_address is not None: + assert state.builders[builder_index].execution_address == expected_execution_address, ( + f"expected_execution_address: expected={expected_execution_address}, " + f"got={state.builders[builder_index].execution_address}" + ) + + if expected_builder_withdrawable_epoch is not None: + assert ( + state.builders[builder_index].withdrawable_epoch == expected_builder_withdrawable_epoch + ), ( + f"expected_builder_withdrawable_epoch: expected={expected_builder_withdrawable_epoch}, " + f"got={state.builders[builder_index].withdrawable_epoch}" + ) + + if slot_reused is True: + assert len(state.builders) == len(pre_state.builders), ( + f"slot_reused=True: builder count should be unchanged: " + f"pre={len(pre_state.builders)}, post={len(state.builders)}" + ) + # Verify exactly one original builder was replaced and it met reuse criteria + changed_indices = [ + i for i in range(len(pre_state.builders)) if state.builders[i] != pre_state.builders[i] + ] + assert len(changed_indices) == 1, ( + f"slot_reused=True: exactly one builder should change: " + f"changed_indices={changed_indices}" + ) + reused_idx = changed_indices[0] + pre_builder = pre_state.builders[reused_idx] + current_epoch = spec.get_current_epoch(pre_state) + assert pre_builder.withdrawable_epoch <= current_epoch, ( + f"slot_reused=True: reused builder at index {reused_idx} must have " + f"withdrawable_epoch <= current_epoch: " + f"withdrawable_epoch={pre_builder.withdrawable_epoch}, current_epoch={current_epoch}" + ) + assert pre_builder.balance == 0, ( + f"slot_reused=True: reused builder at index {reused_idx} must have " + f"zero balance: balance={pre_builder.balance}" + ) + elif slot_reused is False: + assert len(state.builders) == len(pre_state.builders) + 1, ( + f"slot_reused=False: builder count should increase by 1: " + f"pre={len(pre_state.builders)}, post={len(state.builders)}" + ) + # Verify original builders are unchanged when new builder is appended + for i in range(len(pre_state.builders)): + assert state.builders[i] == pre_state.builders[i], ( + f"slot_reused=False: original builder at index {i} should be unchanged" + ) diff --git a/tests/infra/helpers/deposit_requests.py b/tests/infra/helpers/deposit_requests.py index ac1755f54d2..f55726fa1da 100644 --- a/tests/infra/helpers/deposit_requests.py +++ b/tests/infra/helpers/deposit_requests.py @@ -1,13 +1,7 @@ import pytest from eth_consensus_specs.test.helpers.deposits import build_deposit_data -from eth_consensus_specs.test.helpers.forks import is_post_gloas -from eth_consensus_specs.test.helpers.keys import ( - builder_pubkey_to_privkey, - builder_pubkeys, - privkeys, - pubkeys, -) +from eth_consensus_specs.test.helpers.keys import privkeys, pubkeys from eth_consensus_specs.test.helpers.state import next_epoch @@ -38,49 +32,29 @@ def prepare_process_deposit_request( state, advance_epochs=None, validator_index=None, - builder_index=None, pubkey=None, withdrawal_credentials=None, amount=None, signed=False, - for_builder=False, - builders=None, - builder_modifications=None, ): """ - Prepare a deposit request operation with configurable parameters. + Prepare a validator deposit request operation with configurable parameters. - This helper creates a DepositRequest object and optionally modifies - state fields related to deposit request processing. - - The process_deposit_request function behavior varies by fork: - - Electra/Fulu: Sets deposit_requests_start_index if UNSET, appends PendingDeposit - - Gloas+: For builder deposits (0x03 prefix or existing builder pubkey), applies - deposit immediately via apply_deposit_for_builder. For validator deposits, - appends PendingDeposit (does NOT set deposit_requests_start_index). + This helper creates a DepositRequest object. process_deposit_request sets + deposit_requests_start_index if UNSET (Electra only) and appends a + PendingDeposit. Args: spec: The spec object. state: The beacon state to modify. advance_epochs: Number of epochs to advance before setup. validator_index: Index for pubkey lookup. If None, uses len(state.validators) for new - validator. Ignored if for_builder=True or builder_index is set. - builder_index: Index for builder pubkey lookup. If set, implies for_builder=True. - If None with for_builder=True, uses len(state.builders). - pubkey: Explicit BLSPubkey. Default: derived from validator_index or builder_index. - withdrawal_credentials: Explicit Bytes32 credentials. Default: BLS prefix (0x00) for - validators, Builder prefix (0x03) for builders. + validator. + pubkey: Explicit BLSPubkey. Default: derived from validator_index. + withdrawal_credentials: Explicit Bytes32 credentials. Default: BLS prefix (0x00) + + hash(pubkey)[1:]. amount: Deposit amount in Gwei. Default: MIN_ACTIVATION_BALANCE. signed: If True, sign with valid BLS signature. - for_builder: If True, create a builder deposit using builder keys and - BUILDER_WITHDRAWAL_PREFIX (Gloas+). - builders: Override state.builders list entirely. Use [] for empty registry. - builder_modifications: Dict mapping builder index to dict of field modifications. - Supported fields: "withdrawable_epoch", "balance". - Example: {0: {"withdrawable_epoch": 5, "balance": 0}} sets builder[0] - to withdrawable at epoch 5 with zero balance. - Use "current_epoch" as a special value to set to current epoch. - Use "current_epoch-1" or "current_epoch+1" for relative epochs. Returns: DepositRequest: The deposit request. @@ -90,62 +64,21 @@ def prepare_process_deposit_request( for _ in range(advance_epochs): next_epoch(spec, state) - # Phase 2: Determine if this is a builder deposit - is_builder_deposit = for_builder or builder_index is not None - - # Phase 3: Derive effective values based on deposit type - if is_builder_deposit: - # Builder deposit: use builder keys - index = builder_index if builder_index is not None else len(state.builders) - effective_pubkey = pubkey if pubkey is not None else builder_pubkeys[index] - effective_privkey = builder_pubkey_to_privkey[effective_pubkey] - effective_amount = amount if amount is not None else spec.MIN_ACTIVATION_BALANCE - - # Default withdrawal credentials: Builder prefix (0x03) - if withdrawal_credentials is not None: - effective_withdrawal_credentials = withdrawal_credentials - else: - effective_withdrawal_credentials = ( - spec.BUILDER_WITHDRAWAL_PREFIX - + b"\x00" * 11 - + spec.hash(effective_pubkey)[12:] # 20-byte eth1 address - ) + # Phase 2: Derive effective values + index = validator_index if validator_index is not None else len(state.validators) + effective_pubkey = pubkey if pubkey is not None else pubkeys[index] + effective_privkey = privkeys[index] + effective_amount = amount if amount is not None else spec.MIN_ACTIVATION_BALANCE + + # Default withdrawal credentials: BLS prefix + hash(pubkey)[1:] + if withdrawal_credentials is not None: + effective_withdrawal_credentials = withdrawal_credentials else: - # Validator deposit: use validator keys - index = validator_index if validator_index is not None else len(state.validators) - effective_pubkey = pubkey if pubkey is not None else pubkeys[index] - effective_privkey = privkeys[index] - effective_amount = amount if amount is not None else spec.MIN_ACTIVATION_BALANCE - - # Default withdrawal credentials: BLS prefix + hash(pubkey)[1:] - if withdrawal_credentials is not None: - effective_withdrawal_credentials = withdrawal_credentials - else: - effective_withdrawal_credentials = ( - spec.BLS_WITHDRAWAL_PREFIX + spec.hash(effective_pubkey)[1:] - ) - - # Phase 4: Apply state overrides (before creating request) - if builders is not None: - state.builders = builders - - if builder_modifications is not None: - current_epoch = spec.get_current_epoch(state) - for idx, mods in builder_modifications.items(): - if "withdrawable_epoch" in mods: - epoch_value = mods["withdrawable_epoch"] - # Support special string values for relative epochs - if epoch_value == "current_epoch": - epoch_value = current_epoch - elif epoch_value == "current_epoch-1": - epoch_value = current_epoch - 1 - elif epoch_value == "current_epoch+1": - epoch_value = current_epoch + 1 - state.builders[idx].withdrawable_epoch = epoch_value - if "balance" in mods: - state.builders[idx].balance = spec.Gwei(mods["balance"]) - - # Phase 5: Build deposit data and optionally sign + effective_withdrawal_credentials = ( + spec.BLS_WITHDRAWAL_PREFIX + spec.hash(effective_pubkey)[1:] + ) + + # Phase 3: Build deposit data and optionally sign deposit_data = build_deposit_data( spec, effective_pubkey, @@ -155,7 +88,7 @@ def prepare_process_deposit_request( signed=signed, ) - # Phase 6: Build deposit request object + # Phase 4: Build deposit request object deposit_request = spec.DepositRequest( pubkey=deposit_data.pubkey, withdrawal_credentials=deposit_data.withdrawal_credentials, @@ -167,68 +100,32 @@ def prepare_process_deposit_request( return deposit_request -def _is_builder_deposit(spec, pre_state, deposit_request): - """Check if request routes to builder path under Gloas+ deposit routing rules.""" - if not is_post_gloas(spec): - return False - builder_pubkeys = {builder.pubkey for builder in pre_state.builders} - validator_pubkeys = {v.pubkey for v in pre_state.validators} - is_builder = deposit_request.pubkey in builder_pubkeys - is_validator = deposit_request.pubkey in validator_pubkeys - return is_builder or ( - spec.is_builder_withdrawal_credential(deposit_request.withdrawal_credentials) - and not is_validator - and not spec.is_pending_validator(pre_state.pending_deposits, deposit_request.pubkey) - ) - - def assert_process_deposit_request( spec, state, pre_state, deposit_request=None, state_unchanged=False, - is_builder_deposit=None, expected_deposit_requests_start_index=None, expected_pending_deposit_pubkey=None, expected_pending_deposit_amount=None, expected_pending_deposit_slot=None, expected_pending_deposit_credentials=None, - expected_builder_balance=None, - expected_builder_balance_delta=None, - expected_builder_count=None, - expected_builder_index=None, - expected_execution_address=None, - expected_builder_withdrawable_epoch=None, - slot_reused=None, ): """ - Assert expected outcomes from process_deposit_request. + Assert expected outcomes from process_deposit_request (validator deposits). - INVARIANT CHECKS FOR VALIDATOR DEPOSITS (always run): + INVARIANT CHECKS (always run): - pending_deposits increases by exactly 1 - New pending deposit has correct pubkey, withdrawal_credentials, amount, signature - New pending deposit slot equals state.slot - - deposit_requests_start_index is set only if previously UNSET (Electra/Fulu only) + - deposit_requests_start_index is set only if previously UNSET (Electra only) - Validator count unchanged (validators created during epoch processing) - Balances unchanged (balance applied during epoch processing) - INVARIANT CHECKS FOR BUILDER DEPOSITS (Gloas+): - - pending_deposits unchanged (builder deposits applied immediately) - - Builder balance increases by deposit amount - - New builder created if pubkey didn't exist - TEST-SPECIFIC CHECKS (controlled by parameters): - expected_deposit_requests_start_index: Exact value check - expected_pending_deposit_*: Check specific fields of the new deposit - - expected_builder_balance: Exact builder balance check - - expected_builder_balance_delta: Builder balance change check - - expected_builder_count: Exact builder count check - - expected_builder_index: Verify builder at specific index - - expected_execution_address: Verify builder's execution_address - - expected_builder_withdrawable_epoch: Expected withdrawable_epoch of new builder - - slot_reused: True = count same (slot reused), False = count +1 (new slot). - When False, also verifies original builders are unchanged. Args: spec: The spec module for the fork being tested @@ -236,20 +133,11 @@ def assert_process_deposit_request( pre_state: State before deposit request was processed deposit_request: The deposit request that was processed (required for invariant checks) state_unchanged: If True, asserts state equals pre_state (for rejected requests) - is_builder_deposit: If True, use builder deposit assertions. Auto-detected if None. expected_deposit_requests_start_index: Expected value of deposit_requests_start_index expected_pending_deposit_pubkey: Expected pubkey of new pending deposit expected_pending_deposit_amount: Expected amount of new pending deposit expected_pending_deposit_slot: Expected slot of new pending deposit expected_pending_deposit_credentials: Expected withdrawal_credentials of new pending deposit - expected_builder_balance: Expected balance of the builder after deposit - expected_builder_balance_delta: Expected change in builder balance - expected_builder_count: Expected exact count of builders after deposit - expected_builder_index: Expected index of the builder (for verifying slot reuse) - expected_execution_address: Expected execution_address of the new builder - expected_builder_withdrawable_epoch: Expected withdrawable_epoch of the builder - slot_reused: If True, assert builder count unchanged; if False, assert +1 - and verify all original builders are unchanged """ if state_unchanged: assert state == pre_state, "Expected state to be unchanged after rejected deposit request" @@ -258,247 +146,72 @@ def assert_process_deposit_request( # Invariant checks require deposit_request assert deposit_request is not None, "deposit_request required when state_unchanged=False" - # Auto-detect builder deposit if not specified - if is_builder_deposit is None: - is_builder_deposit = _is_builder_deposit(spec, pre_state, deposit_request) - - if is_builder_deposit and is_post_gloas(spec): - # BUILDER DEPOSIT ASSERTIONS (Gloas+) - # Builder deposits are applied immediately, not queued - - # INVARIANT: pending_deposits unchanged for builder deposits - assert len(state.pending_deposits) == len(pre_state.pending_deposits), ( - f"pending_deposits should not change for builder deposits: " - f"pre={len(pre_state.pending_deposits)}, post={len(state.pending_deposits)}" - ) + # INVARIANT: pending_deposits increases by exactly 1 + assert len(state.pending_deposits) == len(pre_state.pending_deposits) + 1, ( + f"pending_deposits should increase by 1: " + f"pre={len(pre_state.pending_deposits)}, post={len(state.pending_deposits)}" + ) - # Find the builder by pubkey - builder_index = None - for i, builder in enumerate(state.builders): - if builder.pubkey == deposit_request.pubkey: - builder_index = i - break + # INVARIANT: New pending deposit matches deposit request fields + # Use len()-1 as SSZ Lists may not support negative indexing + new_pending_deposit = state.pending_deposits[len(state.pending_deposits) - 1] - assert builder_index is not None, ( - f"Builder with pubkey {deposit_request.pubkey[:8]}... should exist after deposit" - ) + assert new_pending_deposit.pubkey == deposit_request.pubkey, ( + "Pending deposit pubkey must match deposit request" + ) + assert new_pending_deposit.withdrawal_credentials == deposit_request.withdrawal_credentials, ( + "Pending deposit withdrawal_credentials must match deposit request" + ) + assert new_pending_deposit.amount == deposit_request.amount, ( + "Pending deposit amount must match deposit request" + ) + assert new_pending_deposit.signature == deposit_request.signature, ( + "Pending deposit signature must match deposit request" + ) - # Check if this was a new builder or top-up - pre_builder_index = None - for i, builder in enumerate(pre_state.builders): - if builder.pubkey == deposit_request.pubkey: - pre_builder_index = i - break - - if pre_builder_index is None: - # New builder was created (could be appended or reused slot) - # The count either increases by 1 (append) or stays the same (slot reuse) - # If slot_reused is specified, that takes precedence for the check - if slot_reused is None: - # Allow either case (append or reuse) if not explicitly specified - assert len(state.builders) >= len(pre_state.builders), ( - "Builder count should not decrease for new builder deposit" - ) - else: - # Top-up of existing builder - assert len(state.builders) == len(pre_state.builders), ( - "Builder count should not change for top-up deposit" - ) - # Balance should increase - pre_balance = pre_state.builders[pre_builder_index].balance - post_balance = state.builders[builder_index].balance - assert post_balance == pre_balance + deposit_request.amount, ( - f"Builder balance should increase by deposit amount: " - f"pre={pre_balance}, post={post_balance}, amount={deposit_request.amount}" - ) - # All other builders should remain unchanged - for i in range(len(pre_state.builders)): - if i != pre_builder_index: - assert state.builders[i] == pre_state.builders[i], ( - f"Builder at index {i} should be unchanged during top-up" - ) - - # INVARIANT: Validator count unchanged - assert len(state.validators) == len(pre_state.validators), ( - "Validator count should not change during builder deposit processing" - ) + # INVARIANT: New pending deposit slot equals state.slot (at time of processing) + assert new_pending_deposit.slot == state.slot, ( + f"Pending deposit slot must equal state.slot: " + f"deposit.slot={new_pending_deposit.slot}, state.slot={state.slot}" + ) - # INVARIANT: Validator balances unchanged - assert list(state.balances) == list(pre_state.balances), ( - "Validator balances should not change during builder deposit processing" + # INVARIANT: deposit_requests_start_index is set only if it was previously UNSET + was_unset = pre_state.deposit_requests_start_index == spec.UNSET_DEPOSIT_REQUESTS_START_INDEX + if was_unset: + # Should be set to deposit_request.index + assert state.deposit_requests_start_index == deposit_request.index, ( + f"deposit_requests_start_index should be set to request index: " + f"expected={deposit_request.index}, got={state.deposit_requests_start_index}" ) - - # Test-specific checks for builders - if expected_builder_balance is not None: - assert state.builders[builder_index].balance == expected_builder_balance - - if expected_builder_balance_delta is not None: - if pre_builder_index is not None: - pre_balance = pre_state.builders[pre_builder_index].balance - else: - pre_balance = 0 - assert ( - state.builders[builder_index].balance - == pre_balance + expected_builder_balance_delta - ) - - if expected_builder_count is not None: - assert len(state.builders) == expected_builder_count, ( - f"expected_builder_count: expected={expected_builder_count}, " - f"got={len(state.builders)}" - ) - - if expected_builder_index is not None: - assert builder_index == expected_builder_index, ( - f"expected_builder_index: expected={expected_builder_index}, got={builder_index}" - ) - - if expected_execution_address is not None: - assert state.builders[builder_index].execution_address == expected_execution_address, ( - f"expected_execution_address: expected={expected_execution_address}, " - f"got={state.builders[builder_index].execution_address}" - ) - - if expected_builder_withdrawable_epoch is not None: - assert ( - state.builders[builder_index].withdrawable_epoch - == expected_builder_withdrawable_epoch - ), ( - f"expected_builder_withdrawable_epoch: expected={expected_builder_withdrawable_epoch}, " - f"got={state.builders[builder_index].withdrawable_epoch}" - ) - - if slot_reused is True: - assert len(state.builders) == len(pre_state.builders), ( - f"slot_reused=True: builder count should be unchanged: " - f"pre={len(pre_state.builders)}, post={len(state.builders)}" - ) - # Verify exactly one original builder was replaced and it met reuse criteria - changed_indices = [ - i - for i in range(len(pre_state.builders)) - if state.builders[i] != pre_state.builders[i] - ] - assert len(changed_indices) == 1, ( - f"slot_reused=True: exactly one builder should change: " - f"changed_indices={changed_indices}" - ) - reused_idx = changed_indices[0] - pre_builder = pre_state.builders[reused_idx] - current_epoch = spec.get_current_epoch(pre_state) - assert pre_builder.withdrawable_epoch <= current_epoch, ( - f"slot_reused=True: reused builder at index {reused_idx} must have " - f"withdrawable_epoch <= current_epoch: " - f"withdrawable_epoch={pre_builder.withdrawable_epoch}, current_epoch={current_epoch}" - ) - assert pre_builder.balance == 0, ( - f"slot_reused=True: reused builder at index {reused_idx} must have " - f"zero balance: balance={pre_builder.balance}" - ) - elif slot_reused is False: - assert len(state.builders) == len(pre_state.builders) + 1, ( - f"slot_reused=False: builder count should increase by 1: " - f"pre={len(pre_state.builders)}, post={len(state.builders)}" - ) - # Verify original builders are unchanged when new builder is appended - for i in range(len(pre_state.builders)): - assert state.builders[i] == pre_state.builders[i], ( - f"slot_reused=False: original builder at index {i} should be unchanged" - ) - else: - # VALIDATOR DEPOSIT ASSERTIONS - - # Guard: expected_builder_count is meaningless for validator deposits - # (builder count is an invariant that should never change). - assert expected_builder_count is None, ( - "expected_builder_count must not be used with validator deposits: " - "builder count is invariant for validator deposits" + # Should remain unchanged + assert state.deposit_requests_start_index == pre_state.deposit_requests_start_index, ( + "deposit_requests_start_index should not change when already set" ) - # INVARIANT: pending_deposits increases by exactly 1 - assert len(state.pending_deposits) == len(pre_state.pending_deposits) + 1, ( - f"pending_deposits should increase by 1: " - f"pre={len(pre_state.pending_deposits)}, post={len(state.pending_deposits)}" - ) - - # INVARIANT: New pending deposit matches deposit request fields - # Use len()-1 as SSZ Lists may not support negative indexing - new_pending_deposit = state.pending_deposits[len(state.pending_deposits) - 1] - - assert new_pending_deposit.pubkey == deposit_request.pubkey, ( - "Pending deposit pubkey must match deposit request" - ) - assert ( - new_pending_deposit.withdrawal_credentials == deposit_request.withdrawal_credentials - ), "Pending deposit withdrawal_credentials must match deposit request" - assert new_pending_deposit.amount == deposit_request.amount, ( - "Pending deposit amount must match deposit request" - ) - assert new_pending_deposit.signature == deposit_request.signature, ( - "Pending deposit signature must match deposit request" - ) - - # INVARIANT: New pending deposit slot equals state.slot (at time of processing) - assert new_pending_deposit.slot == state.slot, ( - f"Pending deposit slot must equal state.slot: " - f"deposit.slot={new_pending_deposit.slot}, state.slot={state.slot}" - ) - - # INVARIANT: deposit_requests_start_index logic (Electra/Fulu only, removed in Gloas) - if not is_post_gloas(spec): - was_unset = ( - pre_state.deposit_requests_start_index == spec.UNSET_DEPOSIT_REQUESTS_START_INDEX - ) - if was_unset: - # Should be set to deposit_request.index - assert state.deposit_requests_start_index == deposit_request.index, ( - f"deposit_requests_start_index should be set to request index: " - f"expected={deposit_request.index}, got={state.deposit_requests_start_index}" - ) - else: - # Should remain unchanged - assert ( - state.deposit_requests_start_index == pre_state.deposit_requests_start_index - ), "deposit_requests_start_index should not change when already set" - else: - # In Gloas+, deposit_requests_start_index is not modified by process_deposit_request - assert state.deposit_requests_start_index == pre_state.deposit_requests_start_index, ( - "deposit_requests_start_index should not change in Gloas+" - ) - - # INVARIANT: Builder count unchanged (Gloas+ only, builders don't exist pre-Gloas) - if is_post_gloas(spec): - assert len(state.builders) == len(pre_state.builders), ( - "Builder count should not change during validator deposit processing" - ) - - # INVARIANT: Validator count unchanged (new validators created during epoch processing) - assert len(state.validators) == len(pre_state.validators), ( - "Validator count should not change during deposit request processing" - ) + # INVARIANT: Validator count unchanged (new validators created during epoch processing) + assert len(state.validators) == len(pre_state.validators), ( + "Validator count should not change during deposit request processing" + ) - # INVARIANT: Balances unchanged (balance applied during epoch processing) - assert list(state.balances) == list(pre_state.balances), ( - "Balances should not change during deposit request processing" - ) + # INVARIANT: Balances unchanged (balance applied during epoch processing) + assert list(state.balances) == list(pre_state.balances), ( + "Balances should not change during deposit request processing" + ) - # Test-specific checks for validators - if expected_pending_deposit_pubkey is not None: - assert new_pending_deposit.pubkey == expected_pending_deposit_pubkey + # Test-specific checks + if expected_pending_deposit_pubkey is not None: + assert new_pending_deposit.pubkey == expected_pending_deposit_pubkey - if expected_pending_deposit_amount is not None: - assert new_pending_deposit.amount == expected_pending_deposit_amount + if expected_pending_deposit_amount is not None: + assert new_pending_deposit.amount == expected_pending_deposit_amount - if expected_pending_deposit_slot is not None: - assert new_pending_deposit.slot == expected_pending_deposit_slot + if expected_pending_deposit_slot is not None: + assert new_pending_deposit.slot == expected_pending_deposit_slot - if expected_pending_deposit_credentials is not None: - assert ( - new_pending_deposit.withdrawal_credentials == expected_pending_deposit_credentials - ) + if expected_pending_deposit_credentials is not None: + assert new_pending_deposit.withdrawal_credentials == expected_pending_deposit_credentials - # Common test-specific checks if expected_deposit_requests_start_index is not None: assert state.deposit_requests_start_index == expected_deposit_requests_start_index, ( f"deposit_requests_start_index: expected={expected_deposit_requests_start_index}, " diff --git a/tests/infra/helpers/test_deposit_requests.py b/tests/infra/helpers/test_deposit_requests.py index 6fb4ae4ce53..4a10c0650f0 100644 --- a/tests/infra/helpers/test_deposit_requests.py +++ b/tests/infra/helpers/test_deposit_requests.py @@ -1,13 +1,10 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from eth_consensus_specs.test.context import ( spec_state_test, - with_all_phases_from_to, with_electra_and_later, - with_gloas_and_later, ) -from eth_consensus_specs.test.helpers.constants import ELECTRA, GLOAS -from eth_consensus_specs.test.helpers.keys import builder_pubkeys, pubkeys +from eth_consensus_specs.test.helpers.keys import pubkeys from eth_consensus_specs.utils import bls from tests.infra.helpers.deposit_requests import ( assert_process_deposit_request, @@ -119,13 +116,12 @@ def test_assert_validator_deposit(): state.deposit_requests_start_index = deposit_request.index state.slot = slot - with patch("tests.infra.helpers.deposit_requests.is_post_gloas", return_value=False): - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - ) + assert_process_deposit_request( + spec, + state, + pre_state, + deposit_request=deposit_request, + ) def test_assert_process_deposit_request_start_index_set(): @@ -164,14 +160,13 @@ def test_assert_process_deposit_request_start_index_set(): state.deposit_requests_start_index = 0 state.slot = slot - with patch("tests.infra.helpers.deposit_requests.is_post_gloas", return_value=False): - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - expected_deposit_requests_start_index=0, - ) + assert_process_deposit_request( + spec, + state, + pre_state, + deposit_request=deposit_request, + expected_deposit_requests_start_index=0, + ) def test_assert_process_deposit_request_start_index_unchanged(): @@ -211,111 +206,10 @@ def test_assert_process_deposit_request_start_index_unchanged(): state.deposit_requests_start_index = initial_start_index state.slot = slot - with patch("tests.infra.helpers.deposit_requests.is_post_gloas", return_value=False): - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - expected_deposit_requests_start_index=initial_start_index, - ) - - -# ============================================================================ -# Builder deposit tests (Gloas+) -# ============================================================================ - - -@with_gloas_and_later -@spec_state_test -def test_prepare_process_deposit_request_for_builder(spec, state): - """Test prepare_process_deposit_request with for_builder=True.""" - builder_index = len(state.builders) - deposit_request = prepare_process_deposit_request(spec, state, for_builder=True) - - # Should use builder pubkey - assert deposit_request.pubkey == builder_pubkeys[builder_index] - - # Should use BUILDER_WITHDRAWAL_PREFIX (0x03) - assert deposit_request.withdrawal_credentials[0:1] == spec.BUILDER_WITHDRAWAL_PREFIX - - # Default amount should be MIN_ACTIVATION_BALANCE - assert deposit_request.amount == spec.MIN_ACTIVATION_BALANCE - - -@with_gloas_and_later -@spec_state_test -def test_prepare_process_deposit_request_builder_custom_amount(spec, state): - """Test prepare_process_deposit_request for builder with custom amount.""" - custom_amount = spec.Gwei(100_000_000_000) # 100 ETH - deposit_request = prepare_process_deposit_request( - spec, state, for_builder=True, amount=custom_amount - ) - - assert deposit_request.amount == custom_amount - - -def test_assert_new_builder_deposit(): - """Test assert_process_deposit_request passes for a new builder deposit.""" - spec = MagicMock() - - builder_pubkey = b"\x03" * 48 - deposit_request = MagicMock() - deposit_request.pubkey = builder_pubkey - deposit_request.withdrawal_credentials = b"\x03" + b"\x00" * 11 + b"\x59" * 20 - deposit_request.amount = 32_000_000_000 - deposit_request.signature = b"\x02" * 96 - deposit_request.index = 0 - - # Pre state: no builders - pre_state = MagicMock() - pre_state.pending_deposits = [] - pre_state.validators = [MagicMock()] - pre_state.balances = [32_000_000_000] - pre_state.builders = [] - - # Post state: one new builder with matching pubkey - new_builder = MagicMock() - new_builder.pubkey = builder_pubkey - new_builder.balance = deposit_request.amount - - state = MagicMock() - state.pending_deposits = [] - state.validators = [MagicMock()] - state.balances = [32_000_000_000] - state.builders = [new_builder] - - with patch("tests.infra.helpers.deposit_requests.is_post_gloas", return_value=True): - assert_process_deposit_request( - spec, - state, - pre_state, - deposit_request=deposit_request, - is_builder_deposit=True, - ) - - -@with_all_phases_from_to(ELECTRA, GLOAS) -@spec_state_test -def test_prepare_process_deposit_request_builder_credentials_before_gloas(spec, state): - """Test prepare_process_deposit_request with builder credentials (0x03) before Gloas.""" - amount = spec.MIN_ACTIVATION_BALANCE - - # Builder withdrawal credentials (0x03 prefix) - no special meaning before Gloas - withdrawal_credentials = b"\x03" + b"\x00" * 11 + b"\x59" * 20 - - deposit_request = prepare_process_deposit_request( + assert_process_deposit_request( spec, state, - amount=amount, - signed=True, - withdrawal_credentials=withdrawal_credentials, + pre_state, + deposit_request=deposit_request, + expected_deposit_requests_start_index=initial_start_index, ) - - # Should create a valid deposit request with the builder credentials preserved - assert deposit_request.withdrawal_credentials == withdrawal_credentials - assert deposit_request.amount == amount - - # Should use validator keys (not builder keys) since for_builder is False - validator_index = len(state.validators) - assert deposit_request.pubkey == pubkeys[validator_index]