# Crowding the validator sweep with builder top-ups
Repeated top-ups to exited builders can crowd the validator sweep. This is a similar scenario to the one presented in:
- [Reduce minimum builder withdrawability delay #4869](https://github.com/ethereum/consensus-specs/pull/4869)
but instead of cycling new builder exits you just keep refunding exited ones.
`get_builders_sweep_withdrawals` pays out a builder's full balance once it's past `withdrawable_epoch`, then `apply_withdrawals` zeroes it:
```python
def get_builders_sweep_withdrawals(state, withdrawal_index, prior_withdrawals):
...
for _ in range(builders_limit):
...
builder = state.builders[builder_index]
if builder.withdrawable_epoch <= epoch and builder.balance > 0:
withdrawals.append(Withdrawal(
...
address=builder.execution_address,
amount=builder.balance,
))
...
```
To make it pay out again you only need to refund it. A top-up to an existing builder skips the pending-deposit queue (`process_deposit_request` applies it immediately via the `is_builder` path) and just bumps the balance, without touching `withdrawable_epoch`:
```python
def apply_deposit_for_builder(state, pubkey, withdrawal_credentials, amount, signature, slot):
builder_pubkeys = [b.pubkey for b in state.builders]
if pubkey not in builder_pubkeys:
if is_valid_deposit_signature(pubkey, withdrawal_credentials, amount, signature):
add_builder_to_registry(state, pubkey, withdrawal_credentials, amount, slot)
else:
# Increase balance by deposit amount
builder_index = builder_pubkeys.index(pubkey)
state.builders[builder_index].balance += amount
```
Because the builder is already exited, a top-up makes it sweepable right away. A payload's deposits are applied at the start of the next block, just before that block's `process_withdrawals`, so the sweep sees the new balance and pays it out the same block, back to the builder's own `execution_address`. The builder isn't removed when swept, it just drops to zero balance, so the attacker refunds the same pubkey and it gets swept again.
```python
def process_block(state, block):
process_parent_execution_payload(state, block) # applies the parent payload's deposits (the top-up)
process_block_header(state, block)
process_withdrawals(state) # the sweep
...
```
A fixed set of exited builders can hold the builder sweep at the max for free/gas cost.
Within `get_expected_withdrawals`, the four sources are processed in a fixed order, all drawing from the same per-block `MAX_WITHDRAWALS_PER_PAYLOAD` budget:
1. `get_builder_withdrawals`: builder bid payments
2. `get_pending_partial_withdrawals`: EIP-7002 partial withdrawal requests
3. `get_builders_sweep_withdrawals`: exited-builder sweep (filled by the attack)
4. `get_validators_sweep_withdrawals`: validator sweep (full exits and excess-balance partials)
The builder sweep runs third, so it can only take what's left after the first two phases. It takes the validator sweep budget that runs last.
Fortunately the validator sweep can't be halted entirely, the first phases stop once the running total hits `MAX_WITHDRAWALS_PER_PAYLOAD - 1`, and the validator sweep asserts it always has the last slot:
```python
def get_validators_sweep_withdrawals(state, withdrawal_index, prior_withdrawals):
...
withdrawals_limit = MAX_WITHDRAWALS_PER_PAYLOAD
# There must be at least one space reserved for validator sweep withdrawals
assert len(prior_withdrawals) < withdrawals_limit
...
```
But then it can be held at a 1 per block rate indefinitely.