# Nimbus: EIP-7732 (ePBS) state and fork transition for Gloas vs. consensus-specs

Scope: a review that compares public source code with the spec. Nothing was run against any network or node. Code was read from shallow clones of the public repositories.
Gloas/Glamsterdam is not live on mainnet. None of the Gloas-only code paths below are shared with live Fulu logic, and this review did not examine shared Fulu logic.

## 1. Pins

| Repo | Ref | Commit | Date (committer) |
|---|---|---|---|
| status-im/nimbus-eth2 | `stable` | `0abc0dd0fc0750a8875c49089d074f7d7197b62c` | 2026-10-23 |
| status-im/nimbus-eth2 | `unstable` | `5babd698516450d8b8e60c2af610a5853baaa9a9` | 2026-10-27 |
| ethereum/consensus-specs | `master` | `8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2` | 2026-10-27 |
| OffchainLabs/prysm | `develop` | `eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a` | 2026-10-27 |

Each pin came from `api.github.com/repos/<repo>/commits/<ref>`.

Link prefixes used below:
- `NB` = https://github.com/status-im/nimbus-eth2/blob/0abc0dd0fc0750a8875c49089d074f7d7197b62c/
- `SP` = https://github.com/ethereum/consensus-specs/blob/8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2/specs/gloas/
- `PY` = https://github.com/OffchainLabs/prysm/blob/eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a/

**Spec version the client targets.** `NB beacon_chain/spec/datatypes/base.nim#L83` reads `const SPEC_VERSION* = "1.7.0-beta.4"`. The line is identical on `unstable`. The Nimbus onboarding code matches v1.7.0-beta.4 `fork.md` line for line (section 3, C1), so the client targets **beta.4**.

consensus-specs PR #5736 ("Separate builder and validator deposits at the Gloas fork") was merged 2026-10-26T19:16:28Z (`api.github.com/repos/ethereum/consensus-specs/pulls/5736`: merged=true). Diffing beta.4 `beacon-chain.md` against master shows only a one-word comment typo fix (EIP-7521 → EIP-7251). That confirms the brief's statement that master differs from beta.4 only in the `fork.md` onboarding.

**Stable vs. unstable.** I diffed `beaconstate.nim`, `state_transition_block.nim`, `state_transition_epoch.nim`, `datatypes/gloas.nim`, `validator.nim` and `datatypes/constants.nim`. Only two differences exist, and neither changes behaviour:
- `gloas.nim#L134`: `extra_data` is written as `List[byte, MAX_EXTRA_DATA_BYTES]` on stable and `ByteList[MAX_EXTRA_DATA_BYTES]` on unstable. Both are the same SSZ type.
- `state_transition_epoch.nim`: unstable removes a local `JUSTIFICATION_BITS_LENGTH` constant.

Every finding below therefore applies to both branches.

## 2. EIPs

- EIP-7732 "Enshrined Proposer-Builder Separation": https://eips.ethereum.org/EIPS/eip-7732
- EIP-8282 "Builder Execution Requests": https://eips.ethereum.org/EIPS/eip-8282

The spec marks the fields and functions for these EIPs with `[New in Gloas:EIP7732]` and `[New in Gloas:EIP8282]`. Master also uses EIP-7688 progressive SSZ types (`ProgressiveContainer`, `ProgressiveList`), and Nimbus implements them (section 4).

## 3. Candidates

### C1. Spec-version lag: `onboard_builders_from_pending_deposits` follows beta.4, not master (PR 5736)

**(1) Spec (master).** `SP fork.md`, section "New `onboard_builders_from_pending_deposits`":

> "Only deposits with builder withdrawal credentials can fund builders. Other deposits stay queued, even if a builder uses the same public key. A valid builder registration can use a validator's public key."

```python
for deposit in state.pending_deposits:
    # Only builder-credential deposits can fund builders at the fork.
    if not is_builder_withdrawal_credential(deposit.withdrawal_credentials):
        pending_deposits.append(deposit)
        continue
    builder_pubkeys = [builder.pubkey for builder in state.builders]
    if deposit.pubkey not in builder_pubkeys:
        if not is_valid_deposit_signature(...):
            # Preserve validator top-ups when no builder can receive them.
            if deposit.pubkey in validator_pubkeys or is_pending_validator(
                pending_deposits, deposit.pubkey
            ):
                pending_deposits.append(deposit)
            continue
        add_builder_to_registry(...)
    else:
        ...balance += deposit.amount
```

beta.4 (https://github.com/ethereum/consensus-specs/blob/v1.7.0-beta.4/specs/gloas/fork.md) works differently. It first keeps every deposit whose pubkey belongs to an existing validator. It then tops up any existing builder whatever the credential. For a new pubkey, it keeps the deposit if the pubkey is a pending validator and drops it if the signature is invalid.

**(2) Client.** `NB beacon_chain/spec/beaconstate.nim#L2308-L2405` (the code cites "v1.7.0-alpha.12" at L2308):

```nim
L2360  for deposit in state.pending_deposits:
L2361    # Deposits for existing validators stay in the pending queue
L2362    if findValidatorIndex(
L2363        state.validators.asSeq, bucket_sorted_validators[],
L2364        deposit.pubkey).isSome:
L2365      pending_deposits.add(deposit)
L2366      continue
...
L2375    if opt_builder_index.isNone:
...
L2378      if not is_builder_withdrawal_credential(deposit.withdrawal_credentials):
L2379        add_candidate_validator_deposit(deposit)
L2380        continue
L2382      if is_pending_validator(deposit.pubkey):
L2383        pending_deposits.add(deposit)
L2384        continue
L2386      if not verify_deposit_signature_cached(
...                continue
...    else:
L2401      # Top up the balance of the existing builder
L2402      state.builders.mitem(opt_builder_index.get).balance += deposit.amount
```

It is called from `upgrade_to_next(fulu→gloas)` at `NB beacon_chain/spec/beaconstate.nim#L3104`.

**(3) When results differ.** The post-fork state (`builders`, `pending_deposits`, and therefore the state root of the first Gloas state) differs from master when the Fulu `pending_deposits` queue holds any of the following at the fork:
- (a) A deposit with builder (0xB0) credentials whose pubkey is already a validator. Master onboards it as a builder (valid signature) or keeps it queued (invalid signature). Nimbus always keeps it queued for the validator.
- (b) A deposit with builder credentials and a valid signature whose pubkey also has a valid pending validator deposit earlier in the queue. Master creates the builder. Nimbus keeps the deposit queued.
- (c) A deposit with builder credentials and an invalid signature whose pubkey is a validator or a pending validator. **Disproof:** master keeps it queued, and Nimbus also keeps it (via the existing-validator branch or `is_pending_validator`). Both versions agree, so this is not a difference.
- (d) A deposit with non-builder credentials (0x00/0x01/0x02) whose pubkey became a builder earlier in the same onboarding pass. Master leaves it queued. Nimbus adds its amount to the builder's balance.

These differences are against master. Against beta.4, Nimbus matches.

**(4) Prysm.** `PY beacon-chain/state/state-native/setters_gloas.go#L795-L850` (`OnboardBuildersFromPendingDeposits`) has the same beta.4 structure:
- an existing validator pubkey → keep;
- an existing builder → `increaseBuilderBalance` whatever the credential;
- non-builder credentials → keep;
- `pendingIdx.HasValid` → keep;
- otherwise check the signature and add the builder.

It is called from `PY beacon-chain/core/gloas/upgrade.go#L132`. Prysm therefore **agrees with Nimbus (beta.4)**, not with master.

**(5) Confidence: high** that the code is beta.4 and differs from master. I tried to disprove this in four ways:
- I checked that the client's `SPEC_VERSION` is beta.4 on both branches.
- I diffed beta.4 and master `beacon-chain.md`: only a comment changed, so the lag is confined to `fork.md`.
- I compared the Nimbus loop line by line with both versions of the spec.
- I confirmed that a reachable divergent queue exists. Cases (a), (b) and (d) need only ordinary deposit-contract deposits made before the fork, with no special privilege. The pre-fork caching in `NB beacon_chain/spec/state_transition_block.nim#L1800-L1805` (`cacheBuilderDepositSigs`) does not change the verdict.

What I could not establish is whether any public devnet or testnet's pending queue currently contains such a deposit.

**(6) Severity: consensus split at the Gloas fork epoch** between clients on master semantics and clients on beta.4 semantics (Nimbus, Prysm and, per the brief, Lighthouse), whenever such a deposit is queued. Until a master-based release is cut it is a spec-version lag, not a bug against the release the client targets.

**Known-check: not found** as an open tracking item. Related closed PRs implemented earlier versions of this logic: #8965 "fix Gloas fork transition builder deposit handling", #8955 "fix same-pubkey builder deposits after slot reuse", #9137 "fix O(n^2) gloas builder deposit handling…", #8686 "avoid verifying BLS sigs on non-builders during Gloas fork builder onboarding", and #8887 "pre-verify gloas builder deposits received before gloas fork". All are closed and predate PR 5736 (2026-10-26).

### No other candidates

For every other rule in the brief I found no difference in verdict or state against master. Section 4 lists them, and section 5 records the near-misses I tried to disprove.

## 4. Rules checked and found consistent with master

| Rule | Spec | Nimbus (`NB` …) | Note |
|---|---|---|---|
| `process_block` order: parent payload → header → withdrawals → bid → randao → eth1 → operations(parent_slot) → sync | `SP beacon-chain.md` "Block processing" | `beacon_chain/spec/state_transition_block.nim#L1877-L1906` | `parent_slot` comes from `latest_execution_payload_bid.slot`, read before the bid is overwritten (L1349-L1354). It equals `latest_block_header.slot` (see 5.1). |
| Bid: self-build requires amount 0 and the infinity signature | `process_execution_payload_bid` | `state_transition_block.nim#L1257-L1262` | |
| Bid: builder is active, version is `PAYLOAD_BUILDER_VERSION`, `can_builder_cover_bid`, signature | same | `#L1263-L1278`; `is_active_builder` at `beaconstate.nim#L3399-L3409` (adds a bounds check, which also rejects); `can_builder_cover_bid` at `beaconstate.nim#L3427-L3437` | Same boundaries: `balance < MIN_DEPOSIT_AMOUNT+pending` → false; equality → true. |
| Bid: blob count ≤ `get_blob_parameters(epoch).max_blobs_per_block` | same | `#L1280-L1283` | |
| Bid: `slot == state.slot`, `state.slot > GENESIS_SLOT`, parent_block_hash == `latest_block_hash`, block_hash ≠ parent hash, parent_block_root, prev_randao | same | `#L1285-L1298` | `proposal_slot = state.slot` in block processing (L1332). |
| Pending payment recorded at index `SLOTS_PER_EPOCH + slot % SLOTS_PER_EPOCH`, weight 0, proposer index | same | `#L1334-L1347` | |
| `get_pending_balance_to_withdraw_for_builder` | spec | `beaconstate.nim#L3412-L3424` | |
| `process_withdrawals`: early return when `latest_block_hash != latest_execution_payload_bid.block_hash`; update order | `process_withdrawals` | `state_transition_block.nim#L1555-L1578` | Same order of the update calls. I did not re-derive `get_expected_withdrawals` or the builder sweep internals (`beaconstate.nim#L1841-L2000`) in this pass. |
| `process_parent_execution_payload`: an empty parent needs requests equal to empty; a full parent needs `hash_tree_root(requests) == execution_requests_root` | spec | `state_transition_block.nim#L1374-L1412` | Nimbus checks the per-type limits before the root check, whereas the spec checks them inside `apply_parent_execution_payload`. Both reject. |
| `apply_parent_execution_payload`: settle the payment (current, previous, or older: append) before requests; request order deposits→withdrawals→consolidations→builder_deposits→builder_exits; set availability bit; `latest_block_hash` | spec | `state_transition_block.nim#L1178-L1238` | |
| `settle_builder_payment` | spec | `beaconstate.nim#L267-L279` | |
| `process_builder_deposit_request` (EIP-8282): 0xB0 prefix; new pubkey with a valid signature → add at `state.slot`; existing builder → reset withdrawable if exited and swept, then top up | spec | `state_transition_block.nim#L1122-L1154`; domain `DOMAIN_BUILDER_DEPOSIT` with `GENESIS_FORK_VERSION` at `signatures.nim#L222-L227` | |
| `process_builder_exit_request` | spec | `state_transition_block.nim#L1157-L1176` | |
| `get_index_for_new_builder` / `add_builder_to_registry` | spec | `beaconstate.nim#L2265-L2306` | Nimbus resumes the scan from a cursor that persists within one payload. Balances only grow before the exit requests run, so no index below the cursor can become reusable within the payload. Equivalent. |
| Payment weight in `process_attestation` (`will_set_new_flag && had_no_participation && is_attestation_same_slot && amount>0`) | spec `process_attestation` | `beaconstate.nim#L1392-L1460`; `is_attestation_same_slot` at `#L960-L972` | |
| `slash_validator` clears payments whose `proposer_index` is the slashed validator | spec | `beaconstate.nim#L527-L533` | |
| `process_payload_attestation`: root equals the parent root; `slot+1 == state.slot`; indexed attestation is valid | spec | `state_transition_block.nim#L707-L728` | |
| `get_indexed_payload_attestation` (sorted) / `is_valid_indexed_payload_attestation` (non-empty, sorted, `DOMAIN_PTC_ATTESTER` at the data epoch) | spec | `beaconstate.nim#L3347-L3397` | |
| `get_ptc` window offset `(epoch - state_epoch + 1)*SLOTS_PER_EPOCH + slot%SLOTS_PER_EPOCH` | spec | `beaconstate.nim#L2434-L2456` | See 5.2. |
| `compute_ptc`: seed = sha256(get_seed(PTC)+slot), concatenated committees, `compute_balance_weighted_selection(size=PTC_SIZE, shuffle=False)` | spec | `beaconstate.nim#L2410-L2431` | |
| 16-bit weighted selection: `MAX_RANDOM_VALUE=2**16-1`, offset `i%16*2`, hash `seed+uint_to_bytes(i//16)`, `weight >= MAX_EB_ELECTRA*rv` | spec | `validator.nim#L466-L505` | |
| Quorum = `total_active // SLOTS_PER_EPOCH * 6 // 10` | spec | `state_transition_epoch.nim#L1386-L1392` | |
| `process_builder_pending_payments` (≥ quorum → withdrawal; shift; clear) | spec | `state_transition_epoch.nim#L1395-L1415` | |
| `process_ptc_window` (shift by one epoch, fill `current+MIN_SEED_LOOKAHEAD+1`) | spec | `state_transition_epoch.nim#L1418-L1438` | |
| `process_epoch` order (payments after pending consolidations, before effective-balance updates; PTC window last) | spec | `state_transition_epoch.nim#L1681-L1690` | |
| Envelope checks: signature, beacon_block_root via header with state_root, parent root, builder_index, prev_randao, gas_limit, block_hash, requests root, slot_number, parent_hash, timestamp, withdrawals root | `SP fork-choice.md` `verify_execution_payload_envelope` | `state_transition_block.nim#L1809-L1871` | See 5.3. |
| `upgrade_to_gloas` defaults: fork versions/epoch; progressive wrappers; `latest_execution_payload_bid` (parent hash/root, block hash, randao, gas limit, `BUILDER_INDEX_SELF_BUILD`, header slot, value 0, empty requests root); all-ones availability; `latest_block_hash`; empty builders, payments and withdrawals; `ptc_window` initialised | `SP fork.md` | `beaconstate.nim#L3006-L3106` | Nimbus calls onboarding *before* `initialize_ptc_window`, while the spec does the reverse. Onboarding touches only `builders` and `pending_deposits`, and `compute_ptc` reads neither, so the result is equal. |
| `initialize_ptc_window` (zeroed previous epoch, then current..+lookahead) | `SP fork.md` | `beaconstate.nim#L2487-L2505` | |
| SSZ: `BeaconState` 46 active fields, same order as the spec | `SP beacon-chain.md` | `datatypes/gloas.nim#L340-L455` | |
| SSZ: `ExecutionPayload` (19), `ExecutionPayloadBid` (12), `ExecutionPayloadEnvelope` (5), `BeaconBlockBody` (13), `ExecutionRequests` (5), `PayloadAttestation`/`IndexedPayloadAttestation` (3), `PayloadAttestationData`, `PayloadAttestationMessage`, `Builder`, `BuilderPendingPayment`, `BuilderPendingWithdrawal`, `BuilderDepositRequest`, `BuilderExitRequest` | same | `datatypes/gloas.nim#L120-L560` (`sszActiveFields` pragmas) | Field order matches. |
| Constants: `DOMAIN_BEACON_BUILDER 0x0B`, `PTC_ATTESTER 0x0C`, `PROPOSER_PREFERENCES 0x0D`, `BUILDER_DEPOSIT 0x0E`; `BUILDER_WITHDRAWAL_PREFIX 0xB0`; `BUILDER_INDEX_SELF_BUILD = UINT64_MAX`; `BUILDER_INDEX_FLAG = 2**40`; threshold 6/10; `PAYLOAD_BUILDER_VERSION 0` | `SP beacon-chain.md` tables | `datatypes/constants.nim#L58-L105` | |
| Preset: `PTC_SIZE 512`, `MAX_PAYLOAD_ATTESTATIONS 4`, builder deposit/exit request maxima 64/16, `MAX_BUILDERS_PER_WITHDRAWALS_SWEEP 16384`; config `MIN_BUILDER_WITHDRAWABILITY_DELAY 64` | spec / `configs/mainnet.yaml` | `presets/mainnet/gloas_preset.nim#L16-L33`, `presets.nim#L349` | |

Not reviewed in this pass, so none of these is claimed correct: gossip validation (p2p-interface), fork-choice handlers other than `verify_execution_payload_envelope`, the internals of `get_expected_withdrawals` and the builder sweep, and the Prysm equivalents of the table rows (Prysm functions located: `PY beacon-chain/core/gloas/bid.go#L76`, `parent_payload.go#L19`, `withdrawals.go#L44`, `payload_attestation.go#L47`, `#L131`, `#L383`, `pending_payment.go#L31`).

## 5. Near-misses checked and disproved

1. **Parent slot source.** The spec uses `state.latest_block_header.slot`. Nimbus uses `latest_execution_payload_bid.slot` (`state_transition_block.nim#L1184-L1186`, `#L1349`). The bid in the parent block is asserted to have `bid.slot == state.slot`, so the two are equal. At the fork, `upgrade_to_gloas` sets `bid.slot = latest_block_header.slot`. Equal in all reachable states.
2. **`get_ptc` out of range.** The spec `assert`s. Nimbus yields an empty iterator (`beaconstate.nim#L2445-L2449`). The only caller in block processing pins `data.slot+1 == state.slot`, and a pre-Gloas epoch gives empty indices, which `is_valid_indexed_payload_attestation` rejects. Same verdict (invalid).
3. **Envelope signature domain epoch.** The spec uses `get_domain(state, DOMAIN_BEACON_BUILDER)`, i.e. the state's current epoch. Nimbus uses `payload.slot_number.epoch` (`state_transition_block.nim#L1833-L1837`). They differ only when `slot_number ≠ state.slot`, and Nimbus then rejects at L1863-L1864. Same verdict.
4. **Onboarding vs. PTC window order.** Equal (section 4).

## 6. Known-check (status-im/nimbus-eth2 issues and PRs, open and closed, via `api.github.com/search/issues`)

Queries: `onboard`, `5736`, `gloas fork deposits`, `builder deposit`, `beta.5`, `epbs`, `separate builder validator deposits`, `consensus-specs bump is:open`, `EIP-7732 is:open`, `pending_deposits gloas`.

- C1: **not found** (no issue or PR for PR 5736 or the master onboarding). Related closed PRs: #8965, #8955, #9137, #8686, #8887. The "5736" query matched only an unrelated Nimbus PR #5736 from 2024.
- Already indexed per the brief: #8383 (EIP-7732 fixes, merged) and #8334 (requests and slot number, merged).
- Related open items I saw, outside this item's state-transition scope: #9203 (issue, open) "Gloas: head with an EL-unvalidated payload is treated as execution-valid…" and #9334 (PR, open) "Fix envelope backfilling".

Hive and EEST: I consulted no public Glamsterdam hive or execution-spec-tests result pages for this item. Whether the master fork-transition tests (PR 5736) are already in a reference-test release that Nimbus runs is **not established**.

## 7. URLs read

- https://api.github.com/repos/status-im/nimbus-eth2/commits/stable, …/commits/unstable
- https://api.github.com/repos/ethereum/consensus-specs/commits/master
- https://api.github.com/repos/OffchainLabs/prysm/commits/develop
- https://api.github.com/repos/ethereum/consensus-specs/pulls/5736
- https://github.com/status-im/nimbus-eth2 (shallow clones of `stable` and `unstable`)
- https://github.com/ethereum/consensus-specs (shallow clone of master): `specs/gloas/beacon-chain.md`, `fork.md`, `fork-choice.md`, `builder.md`; `configs/mainnet.yaml`
- https://raw.githubusercontent.com/ethereum/consensus-specs/v1.7.0-beta.4/specs/gloas/fork.md
- https://raw.githubusercontent.com/ethereum/consensus-specs/v1.7.0-beta.4/specs/gloas/beacon-chain.md
- https://github.com/OffchainLabs/prysm (sparse shallow clone of `develop`)
- https://eips.ethereum.org/EIPS/eip-7732, https://eips.ethereum.org/EIPS/eip-8282
- https://api.github.com/search/issues (queries listed in section 6)
