Skip to content

feat: add proposer preferences#9377

Merged
twoeths merged 4 commits into
unstablefrom
cayman/proposer-preferences-api
May 19, 2026
Merged

feat: add proposer preferences#9377
twoeths merged 4 commits into
unstablefrom
cayman/proposer-preferences-api

Conversation

@wemeetagain

Copy link
Copy Markdown
Member

Motivation

  • gloas

Description

AI Assistance Disclosure

  • claude assistance

wemeetagain and others added 2 commits May 18, 2026 17:56
Implements beacon-APIs PR ethereum/beacon-APIs#593 for the gloas fork.

Adds the operational ProposerPreferencesPool keyed (slot, dependent_root)
and pruned per slot tick, plumbs it into BeaconChain, and exposes it via
two new beacon API endpoints:

  GET  /eth/v1/beacon/pool/proposer_preferences (slot-filtered)
  POST /eth/v1/beacon/pool/proposer_preferences (JSON + SSZ bodies)

The POST handler validates each item via the existing gossip validator,
adds to the pool, publishes through gossip (network.publishProposerPreferences),
emits the new proposer_preferences SSE event, and reports per-item failures
with persistInvalidSszValue on REJECT-class errors. The gossip handler
mirrors the same persist-and-emit on a successfully validated incoming
message.

Also resolves the two // TODO GLOAS markers in validateExecutionPayloadBid:

  - IGNORE if no SignedProposerPreferences exists at (bid.slot,
    dependent_root), where dependent_root is derived from bid.parentBlockRoot
    via forkChoice.getAncestor(parentBlockRoot, computeStartSlotAtEpoch(bidEpoch) - 1).
  - REJECT if bid.fee_recipient != preferences.fee_recipient.
  - REJECT if bid.gas_limit != preferences.gas_limit.

The parentBlockRoot in-fork-choice IGNORE was hoisted to the top of the
validator so getAncestor can be called safely; its late copy is removed
as unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ProposerPreferencesService that runs every slot (gloas+) and submits
each local validator's SignedProposerPreferences within SLOTS_PER_EPOCH/4
slots of their proposal slot. Re-submits when the proposer dependent root
for an epoch shifts (reorg / dependent-root change), detected by comparing
the dependentRoot reported by BlockDutiesService against the one we last
submitted under. Submitted-slot tracking is updated only after the batch
POST succeeds so transient API failures retry naturally on the next tick.

Lifts BlockDutiesService ownership from BlockProposingService to validator.ts
so the new service can share the existing per-slot proposer-duty poll.
BlockDutiesService gains a setNotifyBlockProductionFn setter for late-binding
the block-production callback, and a public getProposersAtEpoch(epoch) getter
exposing the per-epoch {dependentRoot, data} cache.

Adds ValidatorStore.signProposerPreferences (mirrors signPayloadAttestation)
and SignableMessageType.PROPOSER_PREFERENCES (enum, type union,
requiresForkInfo, serialization switch) for remote-signer support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wemeetagain wemeetagain requested a review from a team as a code owner May 18, 2026 22:44
@wemeetagain wemeetagain added the spec-gloas Issues targeting the Glamsterdam spec version label May 18, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the Proposer Preferences feature for the Gloas fork, introducing a ProposerPreferencesPool, adding REST API endpoints for preference management, and integrating validation into the execution payload bid process. Feedback identifies a potential DoS vulnerability due to missing length limits on preference lists and a possible crash during ancestor lookups for epoch 0. Additionally, it is recommended to use bigint for gas limit comparisons and error types to prevent precision loss.

const SyncCommitteeMessageListType = ArrayOf(ssz.altair.SyncCommitteeMessage);
const PayloadAttestationListType = ArrayOf(ssz.gloas.PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS);
const PayloadAttestationMessageListType = ArrayOf(ssz.gloas.PayloadAttestationMessage, PTC_SIZE);
const SignedProposerPreferencesListType = ArrayOf(ssz.gloas.SignedProposerPreferences);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The SignedProposerPreferencesListType is defined as an array without a length limit. This could potentially lead to Denial of Service (DoS) attacks if a client submits an excessively large list in the submitSignedProposerPreferences endpoint. It is recommended to add a reasonable limit to the array definition to bound resource consumption during deserialization and processing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not super high priority but we can cap 2 * SLOTS_PER_EPOCH given proposers are known 2 epochs ahead
for the Pool.get() api, it could be higher during unfinality time, so make it 3 * SLOTS_PER_EPOCH would be enough

Comment on lines +66 to +71
const bidEpoch = computeEpochAtSlot(bid.slot);
const dependentRootHex = chain.forkChoice.getAncestor(
parentBlockRootHex,
computeStartSlotAtEpoch(bidEpoch) - 1
).blockRoot;
const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to chain.forkChoice.getAncestor uses computeStartSlotAtEpoch(bidEpoch) - 1 as the target slot. If bidEpoch is 0, this results in a slot of -1, which will cause getAncestor to throw an error and potentially crash the gossip validation task. Although Gloas is a post-genesis fork, handling the bidEpoch === 0 case (e.g., by using slot 0) ensures robustness in all configurations, including devnets.

  const bidEpoch = computeEpochAtSlot(bid.slot);
  const dependentRootSlot = computeStartSlotAtEpoch(bidEpoch);
  const dependentRootHex = chain.forkChoice.getAncestor(
    parentBlockRootHex,
    dependentRootSlot > 0 ? dependentRootSlot - 1 : 0
  ).blockRoot;
  const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex);

Comment on lines 110 to +119
// [REJECT] `bid.gas_limit == proposer_preferences.gas_limit`.
// Both compared against the matching `proposer_preferences` defined above (same branch
// via dependent_root, same proposal_slot).
// TODO GLOAS: Implement once a ProposerPreferencesPool exists.
const bidGasLimit = Number(bid.gasLimit);
if (bidGasLimit !== proposerPreferences.message.gasLimit) {
throw new ExecutionPayloadBidError(GossipAction.REJECT, {
code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH,
builderIndex: bid.builderIndex,
bidGasLimit,
expectedGasLimit: proposerPreferences.message.gasLimit,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Converting bid.gasLimit (a bigint) to a number using Number() can lead to precision loss if the value exceeds Number.MAX_SAFE_INTEGER. It is safer to perform the comparison and error reporting using bigint values throughout.

  // [REJECT] `bid.gas_limit == proposer_preferences.gas_limit`.
  if (bid.gasLimit !== BigInt(proposerPreferences.message.gasLimit)) {
    throw new ExecutionPayloadBidError(GossipAction.REJECT, {
      code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH,
      builderIndex: bid.builderIndex,
      bidGasLimit: bid.gasLimit,
      expectedGasLimit: BigInt(proposerPreferences.message.gasLimit),
    });
  }

Comment on lines +58 to +59
bidGasLimit: number;
expectedGasLimit: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The bidGasLimit and expectedGasLimit fields in the error type should use bigint instead of number to accurately represent the full range of potential gas limit values and avoid precision issues during error reporting.

Suggested change
bidGasLimit: number;
expectedGasLimit: number;
bidGasLimit: bigint;
expectedGasLimit: bigint;

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a623c19d28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

this.submitted.set(epoch, submission);
}

const dependentRootBytes = fromHex(dutiesAtEpoch.dependentRoot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the Gloas proposer dependent root

In post-Fulu/Gloas, this value is sourced from the shared BlockDutiesService cache, which is populated by the v1 getProposerDuties call (packages/validator/src/services/blockDuties.ts:202). The beacon-node implementation explicitly returns a different dependent root only for v2 after Fulu (packages/beacon-node/src/api/impl/validator/index.ts:1219-1222), while proposer-preferences validation checks currentDecisionRoot/nextDecisionRoot. As a result, Lodestar validators sign preferences with the legacy dependent root and their own BN ignores them, leaving no matching preferences for execution payload bids.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. The api is already correct, we should change the p2p validation to use getDependentRoot(block: ProtoBlock, epochDifference: EpochDifference): RootHex { instead

const dependentRootBytes = fromHex(dutiesAtEpoch.dependentRoot);

for (const duty of dutiesAtEpoch.data) {
if (duty.slot <= slot) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Submit after late first-slot duties arrive

For validators assigned to the first slot of an epoch, this service only runs at slot boundaries. The next-epoch duties are populated by BlockDutiesService.pollBeaconProposersNextEpoch one second before the epoch transition (packages/validator/src/services/blockDuties.ts:149-153 and :190-193), after the last-slot tick of this loop has already skipped the missing duties; the next tick is the proposal slot itself, where this line drops the duty. No SignedProposerPreferences is ever submitted for that first-slot proposal, so execution payload bids for that slot are ignored for lack of matching preferences.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, we have a bug here

this.pollBeaconProposersNextEpoch(currentSlot, nextEpoch, signal).catch((e) => {

starting from fulu, we should be able to pool block duties 1 epoch ahead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in #9380

@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Performance Report

🚀🚀 Significant benchmark improvement detected

Benchmark suite Current: 0704983 Previous: 38cd4b0 Ratio
enrSubnets - fastDeserialize 64 bits 759.00 ns/op 2.6230 us/op 0.29
Full benchmark results
Benchmark suite Current: 0704983 Previous: 38cd4b0 Ratio
getPubkeys - index2pubkey - req 1000 vs - 250000 vc 878.34 us/op 1.2986 ms/op 0.68
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 39.639 us/op 40.296 us/op 0.98
BLS verify - blst 743.27 us/op 705.62 us/op 1.05
BLS verifyMultipleSignatures 3 - blst 1.3859 ms/op 1.3187 ms/op 1.05
BLS verifyMultipleSignatures 8 - blst 2.1913 ms/op 2.1023 ms/op 1.04
BLS verifyMultipleSignatures 32 - blst 6.8805 ms/op 6.6954 ms/op 1.03
BLS verifyMultipleSignatures 64 - blst 13.397 ms/op 12.849 ms/op 1.04
BLS verifyMultipleSignatures 128 - blst 25.898 ms/op 25.341 ms/op 1.02
BLS deserializing 10000 signatures 638.14 ms/op 630.50 ms/op 1.01
BLS deserializing 100000 signatures 6.3617 s/op 6.4453 s/op 0.99
BLS verifyMultipleSignatures - same message - 3 - blst 772.26 us/op 796.68 us/op 0.97
BLS verifyMultipleSignatures - same message - 8 - blst 962.63 us/op 908.46 us/op 1.06
BLS verifyMultipleSignatures - same message - 32 - blst 1.5227 ms/op 1.4739 ms/op 1.03
BLS verifyMultipleSignatures - same message - 64 - blst 2.4012 ms/op 2.3679 ms/op 1.01
BLS verifyMultipleSignatures - same message - 128 - blst 4.1194 ms/op 3.9809 ms/op 1.03
BLS aggregatePubkeys 32 - blst 17.794 us/op 17.527 us/op 1.02
BLS aggregatePubkeys 128 - blst 63.993 us/op 63.548 us/op 1.01
getSlashingsAndExits - default max 48.302 us/op 49.393 us/op 0.98
getSlashingsAndExits - 2k 327.81 us/op 404.38 us/op 0.81
proposeBlockBody type=full, size=empty 742.45 us/op 717.15 us/op 1.04
isKnown best case - 1 super set check 188.00 ns/op 171.00 ns/op 1.10
isKnown normal case - 2 super set checks 172.00 ns/op 159.00 ns/op 1.08
isKnown worse case - 16 super set checks 175.00 ns/op 164.00 ns/op 1.07
validate api signedAggregateAndProof - struct 1.5334 ms/op 1.4367 ms/op 1.07
validate gossip signedAggregateAndProof - struct 1.5292 ms/op 1.4589 ms/op 1.05
batch validate gossip attestation - vc 640000 - chunk 32 107.27 us/op 105.38 us/op 1.02
batch validate gossip attestation - vc 640000 - chunk 64 92.832 us/op 92.680 us/op 1.00
batch validate gossip attestation - vc 640000 - chunk 128 86.104 us/op 87.241 us/op 0.99
batch validate gossip attestation - vc 640000 - chunk 256 82.467 us/op 82.252 us/op 1.00
bytes32 toHexString 305.00 ns/op 282.00 ns/op 1.08
bytes32 Buffer.toString(hex) 181.00 ns/op 177.00 ns/op 1.02
bytes32 Buffer.toString(hex) from Uint8Array 260.00 ns/op 237.00 ns/op 1.10
bytes32 Buffer.toString(hex) + 0x 184.00 ns/op 179.00 ns/op 1.03
Return object 10000 times 0.21340 ns/op 0.20670 ns/op 1.03
Throw Error 10000 times 3.3220 us/op 3.1828 us/op 1.04
toHex 102.93 ns/op 87.903 ns/op 1.17
Buffer.from 94.360 ns/op 79.847 ns/op 1.18
shared Buffer 60.926 ns/op 53.441 ns/op 1.14
fastMsgIdFn sha256 / 200 bytes 1.5010 us/op 1.4090 us/op 1.07
fastMsgIdFn h32 xxhash / 200 bytes 170.00 ns/op 154.00 ns/op 1.10
fastMsgIdFn h64 xxhash / 200 bytes 213.00 ns/op 210.00 ns/op 1.01
fastMsgIdFn sha256 / 1000 bytes 4.7720 us/op 4.4980 us/op 1.06
fastMsgIdFn h32 xxhash / 1000 bytes 257.00 ns/op 235.00 ns/op 1.09
fastMsgIdFn h64 xxhash / 1000 bytes 263.00 ns/op 258.00 ns/op 1.02
fastMsgIdFn sha256 / 10000 bytes 42.061 us/op 40.149 us/op 1.05
fastMsgIdFn h32 xxhash / 10000 bytes 1.2840 us/op 1.2410 us/op 1.03
fastMsgIdFn h64 xxhash / 10000 bytes 826.00 ns/op 813.00 ns/op 1.02
send data - 1000 256B messages 4.0321 ms/op 4.5130 ms/op 0.89
send data - 1000 512B messages 4.0763 ms/op 4.5710 ms/op 0.89
send data - 1000 1024B messages 4.2439 ms/op 4.5845 ms/op 0.93
send data - 1000 1200B messages 4.4497 ms/op 4.8677 ms/op 0.91
send data - 1000 2048B messages 4.5717 ms/op 5.0327 ms/op 0.91
send data - 1000 4096B messages 5.2469 ms/op 5.5097 ms/op 0.95
send data - 1000 16384B messages 12.339 ms/op 18.587 ms/op 0.66
send data - 1000 65536B messages 217.78 ms/op 111.21 ms/op 1.96
enrSubnets - fastDeserialize 64 bits 759.00 ns/op 2.6230 us/op 0.29
enrSubnets - ssz BitVector 64 bits 287.00 ns/op 260.00 ns/op 1.10
enrSubnets - fastDeserialize 4 bits 118.00 ns/op 105.00 ns/op 1.12
enrSubnets - ssz BitVector 4 bits 279.00 ns/op 260.00 ns/op 1.07
prioritizePeers score -10:0 att 32-0.1 sync 2-0 210.74 us/op 198.47 us/op 1.06
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 248.76 us/op 227.51 us/op 1.09
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 349.60 us/op 331.06 us/op 1.06
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 608.24 us/op 581.57 us/op 1.05
prioritizePeers score 0:0 att 64-1 sync 4-1 703.02 us/op 678.57 us/op 1.04
array of 16000 items push then shift 1.3119 us/op 1.2066 us/op 1.09
LinkedList of 16000 items push then shift 6.7360 ns/op 7.2170 ns/op 0.93
array of 16000 items push then pop 64.825 ns/op 64.815 ns/op 1.00
LinkedList of 16000 items push then pop 6.0120 ns/op 5.7910 ns/op 1.04
array of 24000 items push then shift 1.9256 us/op 1.7971 us/op 1.07
LinkedList of 24000 items push then shift 6.3360 ns/op 7.0260 ns/op 0.90
array of 24000 items push then pop 89.891 ns/op 91.369 ns/op 0.98
LinkedList of 24000 items push then pop 5.9920 ns/op 5.8080 ns/op 1.03
intersect bitArray bitLen 8 5.4680 ns/op 4.6060 ns/op 1.19
intersect array and set length 8 30.403 ns/op 28.157 ns/op 1.08
intersect bitArray bitLen 128 24.340 ns/op 23.348 ns/op 1.04
intersect array and set length 128 511.82 ns/op 479.21 ns/op 1.07
bitArray.getTrueBitIndexes() bitLen 128 1.0060 us/op 965.00 ns/op 1.04
bitArray.getTrueBitIndexes() bitLen 248 1.7320 us/op 1.6950 us/op 1.02
bitArray.getTrueBitIndexes() bitLen 512 3.5080 us/op 3.4980 us/op 1.00
Full columns - reconstruct all 6 blobs 111.48 us/op 227.71 us/op 0.49
Full columns - reconstruct half of the blobs out of 6 94.100 us/op 114.47 us/op 0.82
Full columns - reconstruct single blob out of 6 33.471 us/op 31.663 us/op 1.06
Half columns - reconstruct all 6 blobs 380.71 ms/op 367.19 ms/op 1.04
Half columns - reconstruct half of the blobs out of 6 192.29 ms/op 185.45 ms/op 1.04
Half columns - reconstruct single blob out of 6 67.334 ms/op 65.827 ms/op 1.02
Full columns - reconstruct all 10 blobs 263.28 us/op 255.80 us/op 1.03
Full columns - reconstruct half of the blobs out of 10 159.45 us/op 218.56 us/op 0.73
Full columns - reconstruct single blob out of 10 32.206 us/op 28.324 us/op 1.14
Half columns - reconstruct all 10 blobs 630.48 ms/op 610.91 ms/op 1.03
Half columns - reconstruct half of the blobs out of 10 319.23 ms/op 307.38 ms/op 1.04
Half columns - reconstruct single blob out of 10 67.494 ms/op 65.524 ms/op 1.03
Full columns - reconstruct all 20 blobs 1.6613 ms/op 2.0448 ms/op 0.81
Full columns - reconstruct half of the blobs out of 20 279.77 us/op 389.23 us/op 0.72
Full columns - reconstruct single blob out of 20 30.083 us/op 31.185 us/op 0.96
Half columns - reconstruct all 20 blobs 1.2569 s/op 1.2160 s/op 1.03
Half columns - reconstruct half of the blobs out of 20 630.22 ms/op 613.02 ms/op 1.03
Half columns - reconstruct single blob out of 20 67.283 ms/op 65.090 ms/op 1.03
Set add up to 64 items then delete first 2.1680 us/op 2.1020 us/op 1.03
OrderedSet add up to 64 items then delete first 3.3732 us/op 3.3072 us/op 1.02
Set add up to 64 items then delete last 2.1815 us/op 2.0549 us/op 1.06
OrderedSet add up to 64 items then delete last 3.3186 us/op 3.3143 us/op 1.00
Set add up to 64 items then delete middle 2.1830 us/op 2.0694 us/op 1.05
OrderedSet add up to 64 items then delete middle 4.7992 us/op 4.7062 us/op 1.02
Set add up to 128 items then delete first 4.3675 us/op 3.9830 us/op 1.10
OrderedSet add up to 128 items then delete first 6.6277 us/op 5.7816 us/op 1.15
Set add up to 128 items then delete last 3.9807 us/op 3.7040 us/op 1.07
OrderedSet add up to 128 items then delete last 5.8419 us/op 5.7963 us/op 1.01
Set add up to 128 items then delete middle 3.9598 us/op 3.7584 us/op 1.05
OrderedSet add up to 128 items then delete middle 11.673 us/op 11.571 us/op 1.01
Set add up to 256 items then delete first 8.0260 us/op 7.3096 us/op 1.10
OrderedSet add up to 256 items then delete first 12.267 us/op 11.197 us/op 1.10
Set add up to 256 items then delete last 7.8201 us/op 7.4303 us/op 1.05
OrderedSet add up to 256 items then delete last 11.620 us/op 11.495 us/op 1.01
Set add up to 256 items then delete middle 7.7822 us/op 7.3454 us/op 1.06
OrderedSet add up to 256 items then delete middle 34.759 us/op 34.643 us/op 1.00
pass gossip attestations to forkchoice per slot 2.5037 ms/op 2.4389 ms/op 1.03
forkChoice updateHead vc 100000 bc 64 eq 0 468.26 us/op 374.88 us/op 1.25
forkChoice updateHead vc 600000 bc 64 eq 0 2.8146 ms/op 2.2212 ms/op 1.27
forkChoice updateHead vc 1000000 bc 64 eq 0 4.6517 ms/op 3.7268 ms/op 1.25
forkChoice updateHead vc 600000 bc 320 eq 0 2.8047 ms/op 2.2396 ms/op 1.25
forkChoice updateHead vc 600000 bc 1200 eq 0 2.8486 ms/op 2.2664 ms/op 1.26
forkChoice updateHead vc 600000 bc 7200 eq 0 3.1118 ms/op 2.5857 ms/op 1.20
forkChoice updateHead vc 600000 bc 64 eq 1000 3.4474 ms/op 2.7654 ms/op 1.25
forkChoice updateHead vc 600000 bc 64 eq 10000 3.5395 ms/op 2.8753 ms/op 1.23
forkChoice updateHead vc 600000 bc 64 eq 300000 7.6056 ms/op 7.1024 ms/op 1.07
computeDeltas 1400000 validators 0% inactive 14.235 ms/op 12.050 ms/op 1.18
computeDeltas 1400000 validators 10% inactive 13.022 ms/op 11.415 ms/op 1.14
computeDeltas 1400000 validators 20% inactive 12.077 ms/op 10.278 ms/op 1.18
computeDeltas 1400000 validators 50% inactive 9.3166 ms/op 7.9438 ms/op 1.17
computeDeltas 2100000 validators 0% inactive 20.686 ms/op 18.045 ms/op 1.15
computeDeltas 2100000 validators 10% inactive 19.447 ms/op 16.751 ms/op 1.16
computeDeltas 2100000 validators 20% inactive 18.141 ms/op 15.286 ms/op 1.19
computeDeltas 2100000 validators 50% inactive 14.138 ms/op 11.986 ms/op 1.18
altair processAttestation - 250000 vs - 7PWei normalcase 1.8506 ms/op 1.7251 ms/op 1.07
altair processAttestation - 250000 vs - 7PWei worstcase 2.7641 ms/op 2.5073 ms/op 1.10
altair processAttestation - setStatus - 1/6 committees join 108.29 us/op 95.661 us/op 1.13
altair processAttestation - setStatus - 1/3 committees join 213.06 us/op 187.17 us/op 1.14
altair processAttestation - setStatus - 1/2 committees join 293.66 us/op 271.10 us/op 1.08
altair processAttestation - setStatus - 2/3 committees join 375.23 us/op 355.29 us/op 1.06
altair processAttestation - setStatus - 4/5 committees join 513.34 us/op 491.02 us/op 1.05
altair processAttestation - setStatus - 100% committees join 615.00 us/op 575.40 us/op 1.07
altair processBlock - 250000 vs - 7PWei normalcase 2.8813 ms/op 3.7195 ms/op 0.77
altair processBlock - 250000 vs - 7PWei normalcase hashState 14.345 ms/op 15.810 ms/op 0.91
altair processBlock - 250000 vs - 7PWei worstcase 19.623 ms/op 20.242 ms/op 0.97
altair processBlock - 250000 vs - 7PWei worstcase hashState 39.058 ms/op 42.117 ms/op 0.93
phase0 processBlock - 250000 vs - 7PWei normalcase 1.3156 ms/op 1.3005 ms/op 1.01
phase0 processBlock - 250000 vs - 7PWei worstcase 17.152 ms/op 16.789 ms/op 1.02
altair processEth1Data - 250000 vs - 7PWei normalcase 304.11 us/op 280.62 us/op 1.08
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 6.8120 us/op 3.0360 us/op 2.24
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 21.496 us/op 20.162 us/op 1.07
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 5.9170 us/op 5.8050 us/op 1.02
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 3.9120 us/op 3.4430 us/op 1.14
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 97.757 us/op 88.975 us/op 1.10
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.3846 ms/op 1.3374 ms/op 1.04
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 1.8391 ms/op 1.8036 ms/op 1.02
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 1.8456 ms/op 1.7696 ms/op 1.04
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 3.6513 ms/op 3.5359 ms/op 1.03
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 2.0893 ms/op 1.9656 ms/op 1.06
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 3.8994 ms/op 3.8654 ms/op 1.01
Tree 40 250000 create 319.38 ms/op 312.49 ms/op 1.02
Tree 40 250000 get(125000) 103.43 ns/op 89.324 ns/op 1.16
Tree 40 250000 set(125000) 1.0881 us/op 965.95 ns/op 1.13
Tree 40 250000 toArray() 9.3817 ms/op 12.036 ms/op 0.78
Tree 40 250000 iterate all - toArray() + loop 9.5233 ms/op 11.995 ms/op 0.79
Tree 40 250000 iterate all - get(i) 34.978 ms/op 36.631 ms/op 0.95
Array 250000 create 2.0977 ms/op 2.1365 ms/op 0.98
Array 250000 clone - spread 651.51 us/op 656.58 us/op 0.99
Array 250000 get(125000) 0.29600 ns/op 0.29500 ns/op 1.00
Array 250000 set(125000) 0.29700 ns/op 0.29600 ns/op 1.00
Array 250000 iterate all - loop 57.108 us/op 56.409 us/op 1.01
phase0 afterProcessEpoch - 250000 vs - 7PWei 39.995 ms/op 54.092 ms/op 0.74
Array.fill - length 1000000 2.2564 ms/op 2.2228 ms/op 1.02
Array push - length 1000000 7.6864 ms/op 9.4010 ms/op 0.82
Array.get 0.20679 ns/op 0.20552 ns/op 1.01
Uint8Array.get 0.26015 ns/op 0.24228 ns/op 1.07
phase0 beforeProcessEpoch - 250000 vs - 7PWei 13.570 ms/op 18.912 ms/op 0.72
altair processEpoch - mainnet_e81889 235.84 ms/op 258.76 ms/op 0.91
mainnet_e81889 - altair beforeProcessEpoch 19.763 ms/op 17.997 ms/op 1.10
mainnet_e81889 - altair processJustificationAndFinalization 4.8820 us/op 6.0960 us/op 0.80
mainnet_e81889 - altair processInactivityUpdates 3.5723 ms/op 3.9364 ms/op 0.91
mainnet_e81889 - altair processRewardsAndPenalties 17.979 ms/op 19.379 ms/op 0.93
mainnet_e81889 - altair processRegistryUpdates 538.00 ns/op 539.00 ns/op 1.00
mainnet_e81889 - altair processSlashings 135.00 ns/op 139.00 ns/op 0.97
mainnet_e81889 - altair processEth1DataReset 130.00 ns/op 134.00 ns/op 0.97
mainnet_e81889 - altair processEffectiveBalanceUpdates 1.7177 ms/op 1.9177 ms/op 0.90
mainnet_e81889 - altair processSlashingsReset 697.00 ns/op 691.00 ns/op 1.01
mainnet_e81889 - altair processRandaoMixesReset 1.0210 us/op 1.1340 us/op 0.90
mainnet_e81889 - altair processHistoricalRootsUpdate 135.00 ns/op 132.00 ns/op 1.02
mainnet_e81889 - altair processParticipationFlagUpdates 424.00 ns/op 415.00 ns/op 1.02
mainnet_e81889 - altair processSyncCommitteeUpdates 106.00 ns/op 111.00 ns/op 0.95
mainnet_e81889 - altair afterProcessEpoch 42.312 ms/op 41.541 ms/op 1.02
capella processEpoch - mainnet_e217614 724.08 ms/op 835.57 ms/op 0.87
mainnet_e217614 - capella beforeProcessEpoch 63.544 ms/op 65.929 ms/op 0.96
mainnet_e217614 - capella processJustificationAndFinalization 5.2770 us/op 5.8220 us/op 0.91
mainnet_e217614 - capella processInactivityUpdates 11.883 ms/op 15.588 ms/op 0.76
mainnet_e217614 - capella processRewardsAndPenalties 88.601 ms/op 93.183 ms/op 0.95
mainnet_e217614 - capella processRegistryUpdates 4.5720 us/op 4.4270 us/op 1.03
mainnet_e217614 - capella processSlashings 133.00 ns/op 146.00 ns/op 0.91
mainnet_e217614 - capella processEth1DataReset 125.00 ns/op 136.00 ns/op 0.92
mainnet_e217614 - capella processEffectiveBalanceUpdates 5.8495 ms/op 17.532 ms/op 0.33
mainnet_e217614 - capella processSlashingsReset 677.00 ns/op 695.00 ns/op 0.97
mainnet_e217614 - capella processRandaoMixesReset 1.0460 us/op 1.3800 us/op 0.76
mainnet_e217614 - capella processHistoricalRootsUpdate 135.00 ns/op 144.00 ns/op 0.94
mainnet_e217614 - capella processParticipationFlagUpdates 420.00 ns/op 438.00 ns/op 0.96
mainnet_e217614 - capella afterProcessEpoch 107.93 ms/op 108.99 ms/op 0.99
phase0 processEpoch - mainnet_e58758 270.34 ms/op 317.52 ms/op 0.85
mainnet_e58758 - phase0 beforeProcessEpoch 52.969 ms/op 71.926 ms/op 0.74
mainnet_e58758 - phase0 processJustificationAndFinalization 5.7510 us/op 6.8290 us/op 0.84
mainnet_e58758 - phase0 processRewardsAndPenalties 15.396 ms/op 15.415 ms/op 1.00
mainnet_e58758 - phase0 processRegistryUpdates 2.2590 us/op 2.2300 us/op 1.01
mainnet_e58758 - phase0 processSlashings 132.00 ns/op 138.00 ns/op 0.96
mainnet_e58758 - phase0 processEth1DataReset 126.00 ns/op 135.00 ns/op 0.93
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 879.58 us/op 1.5554 ms/op 0.57
mainnet_e58758 - phase0 processSlashingsReset 884.00 ns/op 867.00 ns/op 1.02
mainnet_e58758 - phase0 processRandaoMixesReset 2.0030 us/op 1.1960 us/op 1.67
mainnet_e58758 - phase0 processHistoricalRootsUpdate 138.00 ns/op 138.00 ns/op 1.00
mainnet_e58758 - phase0 processParticipationRecordUpdates 1.0310 us/op 1.0370 us/op 0.99
mainnet_e58758 - phase0 afterProcessEpoch 33.927 ms/op 33.059 ms/op 1.03
phase0 processEffectiveBalanceUpdates - 250000 normalcase 1.1335 ms/op 973.18 us/op 1.16
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 1.7228 ms/op 1.9767 ms/op 0.87
altair processInactivityUpdates - 250000 normalcase 10.697 ms/op 11.506 ms/op 0.93
altair processInactivityUpdates - 250000 worstcase 10.701 ms/op 11.701 ms/op 0.91
phase0 processRegistryUpdates - 250000 normalcase 2.2160 us/op 2.0530 us/op 1.08
phase0 processRegistryUpdates - 250000 badcase_full_deposits 151.05 us/op 142.66 us/op 1.06
phase0 processRegistryUpdates - 250000 worstcase 0.5 64.684 ms/op 61.151 ms/op 1.06
altair processRewardsAndPenalties - 250000 normalcase 14.328 ms/op 13.522 ms/op 1.06
altair processRewardsAndPenalties - 250000 worstcase 13.739 ms/op 12.932 ms/op 1.06
phase0 getAttestationDeltas - 250000 normalcase 5.4477 ms/op 5.1194 ms/op 1.06
phase0 getAttestationDeltas - 250000 worstcase 5.5024 ms/op 5.2759 ms/op 1.04
phase0 processSlashings - 250000 worstcase 60.096 us/op 58.104 us/op 1.03
altair processSyncCommitteeUpdates - 250000 10.031 ms/op 9.8572 ms/op 1.02
BeaconState.hashTreeRoot - No change 172.00 ns/op 173.00 ns/op 0.99
BeaconState.hashTreeRoot - 1 full validator 61.433 us/op 68.852 us/op 0.89
BeaconState.hashTreeRoot - 32 full validator 675.13 us/op 812.81 us/op 0.83
BeaconState.hashTreeRoot - 512 full validator 6.3112 ms/op 7.0267 ms/op 0.90
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 76.176 us/op 90.383 us/op 0.84
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 1.1044 ms/op 1.4013 ms/op 0.79
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 13.710 ms/op 14.453 ms/op 0.95
BeaconState.hashTreeRoot - 1 balances 60.286 us/op 71.721 us/op 0.84
BeaconState.hashTreeRoot - 32 balances 654.53 us/op 764.17 us/op 0.86
BeaconState.hashTreeRoot - 512 balances 4.8879 ms/op 5.1582 ms/op 0.95
BeaconState.hashTreeRoot - 250000 balances 113.15 ms/op 145.67 ms/op 0.78
aggregationBits - 2048 els - zipIndexesInBitList 19.899 us/op 19.007 us/op 1.05
regular array get 100000 times 23.168 us/op 22.459 us/op 1.03
wrappedArray get 100000 times 23.155 us/op 22.479 us/op 1.03
arrayWithProxy get 100000 times 10.493 ms/op 9.4314 ms/op 1.11
ssz.Root.equals 21.682 ns/op 21.019 ns/op 1.03
byteArrayEquals 21.502 ns/op 20.707 ns/op 1.04
Buffer.compare 9.3990 ns/op 8.5780 ns/op 1.10
processSlot - 1 slots 8.3130 us/op 9.2810 us/op 0.90
processSlot - 32 slots 1.5873 ms/op 2.1725 ms/op 0.73
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 4.1890 ms/op 4.5993 ms/op 0.91
getCommitteeAssignments - req 1 vs - 250000 vc 1.6964 ms/op 1.6415 ms/op 1.03
getCommitteeAssignments - req 100 vs - 250000 vc 3.4854 ms/op 3.3694 ms/op 1.03
getCommitteeAssignments - req 1000 vs - 250000 vc 3.7407 ms/op 3.6315 ms/op 1.03
findModifiedValidators - 10000 modified validators 621.03 ms/op 678.24 ms/op 0.92
findModifiedValidators - 1000 modified validators 581.24 ms/op 409.44 ms/op 1.42
findModifiedValidators - 100 modified validators 334.91 ms/op 289.13 ms/op 1.16
findModifiedValidators - 10 modified validators 275.56 ms/op 243.47 ms/op 1.13
findModifiedValidators - 1 modified validators 250.72 ms/op 144.23 ms/op 1.74
findModifiedValidators - no difference 245.29 ms/op 145.29 ms/op 1.69
migrate state 1500000 validators, 3400 modified, 2000 new 2.7640 s/op 3.1792 s/op 0.87
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 3.8100 ns/op 3.8400 ns/op 0.99
state getBlockRootAtSlot - 250000 vs - 7PWei 294.13 ns/op 412.67 ns/op 0.71
computeProposerIndex 100000 validators 1.3342 ms/op 1.3400 ms/op 1.00
getNextSyncCommitteeIndices 1000 validators 2.9308 ms/op 2.8119 ms/op 1.04
getNextSyncCommitteeIndices 10000 validators 25.663 ms/op 24.820 ms/op 1.03
getNextSyncCommitteeIndices 100000 validators 85.603 ms/op 86.568 ms/op 0.99
computeProposers - vc 250000 558.97 us/op 545.80 us/op 1.02
computeEpochShuffling - vc 250000 40.817 ms/op 38.069 ms/op 1.07
getNextSyncCommittee - vc 250000 9.7365 ms/op 9.0489 ms/op 1.08
nodejs block root to RootHex using toHex 102.73 ns/op 87.309 ns/op 1.18
nodejs block root to RootHex using toRootHex 63.816 ns/op 55.730 ns/op 1.15
nodejs fromHex(blob) 818.16 us/op 739.86 us/op 1.11
nodejs fromHexInto(blob) 673.34 us/op 613.15 us/op 1.10
nodejs block root to RootHex using the deprecated toHexString 504.66 ns/op 537.42 ns/op 0.94
nodejs byteArrayEquals 32 bytes (block root) 26.322 ns/op 25.468 ns/op 1.03
nodejs byteArrayEquals 48 bytes (pubkey) 38.077 ns/op 36.603 ns/op 1.04
nodejs byteArrayEquals 96 bytes (signature) 39.221 ns/op 33.713 ns/op 1.16
nodejs byteArrayEquals 1024 bytes 45.371 ns/op 44.534 ns/op 1.02
nodejs byteArrayEquals 131072 bytes (blob) 1.7942 us/op 1.7187 us/op 1.04
browser block root to RootHex using toHex 148.08 ns/op 139.54 ns/op 1.06
browser block root to RootHex using toRootHex 153.75 ns/op 126.79 ns/op 1.21
browser fromHex(blob) 1.6307 ms/op 1.4420 ms/op 1.13
browser fromHexInto(blob) 670.72 us/op 603.83 us/op 1.11
browser block root to RootHex using the deprecated toHexString 358.25 ns/op 359.01 ns/op 1.00
browser byteArrayEquals 32 bytes (block root) 28.587 ns/op 27.473 ns/op 1.04
browser byteArrayEquals 48 bytes (pubkey) 40.325 ns/op 38.347 ns/op 1.05
browser byteArrayEquals 96 bytes (signature) 75.181 ns/op 71.251 ns/op 1.06
browser byteArrayEquals 1024 bytes 768.34 ns/op 725.59 ns/op 1.06
browser byteArrayEquals 131072 bytes (blob) 97.081 us/op 91.117 us/op 1.07

by benchmarkbot/action

// root for the bid's branch and epoch. Derived via fork-choice ancestor lookup: the block at the
// last slot of `epoch - 1` reached from `bid.parent_block_root`.
const bidEpoch = computeEpochAtSlot(bid.slot);
const dependentRootHex = chain.forkChoice.getAncestor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not correct according to https://github.com/ethereum/consensus-specs/blob/4a4937bea332d72a55a76aaebcb97fbcdc189f69/specs/gloas/p2p-interface.md?plain=1#L447

def get_proposer_dependent_root(state: BeaconState, epoch: Epoch) -> Root:
    """
    Return the dependent root for the proposer lookahead at ``epoch``.
    """
    return get_block_root_at_slot(
        state, Slot(compute_start_slot_at_epoch(Epoch(epoch - MIN_SEED_LOOKAHEAD)) - 1)
    )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to handle differently at epoch boundary too

Comment thread packages/beacon-node/src/network/processor/gossipHandlers.ts

@twoeths twoeths left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like we can just use the ProposerPreferencesPool in this PR instead of the pre-existing SeenProposerPreferences

something to cleanup later

@twoeths twoeths left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed issues found during review
the only remaining is for block proposer duties post-fulu
should be addressed by #9380, it's too big for this PR, and not the intention of it anyway

@twoeths twoeths merged commit c98da75 into unstable May 19, 2026
25 of 26 checks passed
@twoeths twoeths deleted the cayman/proposer-preferences-api branch May 19, 2026 09:45
wemeetagain added a commit that referenced this pull request May 19, 2026
# Summary

Fixes Lodestar's handling of proposer duties under the post-Fulu
(EIP-7917) deterministic 1-epoch proposer lookahead. Surfaced while
reviewing #9377: the validator was never querying `currentEpoch + 1`
proposer duties post-Fulu, and the BN's dep_root computation was wrong
when serving duties for an epoch other than `state.epoch`.

Contains two related stories:
1. **BN-side bug fixes + lookahead support** so `getProposerDutiesV2`
correctly serves `currentEpoch + 1` (and `currentEpoch + 2` near the
boundary).
2. **Validator-side refactor** to consume that lookahead through an
event-driven model that mirrors `AttestationDutiesService`, instead of
per-slot polling.

# BN side

**`proposerShufflingDecisionRoot` bug fix**
(`state-transition/src/util/shuffling.ts`)

Previously derived the decision slot from `state.epoch`, which gave the
wrong dep_root whenever the state was one epoch off the requested epoch
(e.g. serving `state.epoch + 1` duties from the head state). Now takes
`proposalEpoch` explicitly:

- Pre-Fulu: `dep_root(E) = block@(startSlot(E) - 1)` — unchanged
- Post-Fulu (MIN_SEED_LOOKAHEAD = 1): `dep_root(E) = block@(startSlot(E
- 1) - 1)` — shifted back one epoch

**`getProposerDuties` (`beacon-node/src/api/impl/validator/index.ts`)**

Allows `epoch === currentEpoch + 2` near the next-epoch boundary
post-Fulu. The duties are served from the upcoming-epoch (`currentEpoch
+ 1`) checkpoint state's `nextProposers`, which is populated by the
`proposer_lookahead` field. The existing `nearNextEpoch` gate
(`msToNextEpoch < prepareNextSlotLookAheadMs`) determines availability.

# Validator side

Original draft of this PR added a fork-aware `pollBeaconProposers` that,
post-Fulu, polled `nextEpoch` every slot and `nextEpoch + nextEpoch+1`
at the boundary. That was functional but raised a fair concern in
review: *why fetch two epochs at the boundary, and why poll next-epoch
every slot if its dep_root is stable post-Fulu?*

The refactor (`refactor(validator): event-driven proposer duties via SSE
head events`) replaces that with an attester-style model:

| Trigger | Action |
|---------|--------|
| `clock.runEveryEpoch(epoch)` | Fetch `epoch` (+ `epoch + 1` post-Fulu,
using the EIP-7917 lookahead) |
| `chainHeaderTracker.runOnNewHead(headEvent)` | Compare incoming
dep_roots against cache; refetch only the affected epoch on mismatch |
| `clock.runEverySlot(slot)` | Notify block production from cache;
pre-Fulu only — schedule the 1s-before-boundary fetch for `nextEpoch`
(its dep_root only stabilizes at the boundary and isn't exposed via SSE)
|

The SSE head event already carries everything needed for both forks via
a nice coincidence in the dep_root math:

- **Pre-Fulu:** `currentDutyDependentRoot ≡
proposer_dep_root(currentEpoch)`
- **Post-Fulu:** `previousDutyDependentRoot ≡
proposer_dep_root(currentEpoch)`, `currentDutyDependentRoot ≡
proposer_dep_root(nextEpoch)`

No spec/event changes required — the same fields the validator already
uses for attester duties cover the post-Fulu proposer lookahead window.

A per-slot notification dedup (`notifiedSlot` / `notifiedProposers`)
replaces the old "two-pass with `differenceHex`" pattern so any source
of cache update (SSE refetch, cold-cache back-fill, epoch tick) only
notifies *newly discovered* proposers and never duplicates
`createAndPublishBlock` calls.

# Results

In steady state, the validator now makes **2 proposer-duty calls per
epoch** (current + next epoch pre-fetch) plus refetches only on dep_root
changes — matching the per-epoch cadence of `AttestationDutiesService`
(which previously had been 32× more frequent).

# Tests

- 11 new `BlockDutiesService` unit tests covering: post-Fulu pre-fetch
of next epoch, pre-Fulu vs post-Fulu fork detection, SSE-driven refetch
on dep_root mismatch, no-op on dep_root match, cold-cache back-fill,
pre-Fulu boundary scheduling + post-Fulu suppression, signer removal
across epochs.
- BN-side `getProposerDuties` tests updated to exercise the V2 path with
a post-Fulu config.
- E2E tests verified: `proposerBoostReorg`, `finalizedSync`,
`checkpointSync` (Fulu fork crossings, reorgs, checkpoint sync) — all
pass, all 30+ block proposals fire correctly, no new errors.

# Known follow-ups (non-blocking)

1. **Genesis-state dep_root quirk (BN-side, cosmetic).** At very early
genesis, the BN's `getProposerDuties` returns `genesisBlockRoot` via the
`state.slot === decisionSlot` fallback, but later returns
`state.getBlockRootAtSlot(0)` for the same epoch — they're cosmetically
different roots for the same logical block. The old code didn't observe
this because it didn't pre-fetch `nextEpoch` until ~1s before the
boundary; the new code pre-fetches at the start of epoch 0 and sees one
or two spurious `Proposer duties re-org` warnings per VC at startup.
Duties are correct — pure metric noise. Worth a small BN-side
normalization or a "skip pre-fetch on first epoch tick" guard.

2. **Concurrent `pollBeaconProposers` race.** If `onNewHead` and
`runEveryEpochTask` race on the same epoch with asymmetric HTTP
latencies, last-write-wins can briefly leave a stale dep_root cached. In
practice the same BN serves both calls and returns identical payloads.
Documented in a code comment; a per-epoch sequence number would harden
it if it ever becomes a real problem.

3. **Gloas timing.** `BLOCK_DUTIES_LOOKAHEAD_BPS` may want to flip from
"1s before the boundary" to "1s after" post-Gloas. Existing `TODO GLOAS:
re-evaluate timing` is preserved.

# AI disclosure

Refactor designed and implemented with AI assistance.

---------

Co-authored-by: Cayman <caymannava@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
wemeetagain added a commit that referenced this pull request May 20, 2026
**Motivation**

Upgrade Lodestar to the `v1.7.0-alpha.8` following #9375

**What's changed since #9375**
- consume ProposerPreferencesPool in #9377
- use `PAYLOAD_DUE_BPS` instead of `PAYLOAD_ATTESTATION_DUE_BPS`
- the onboard builder is implemented in #9374, reenable spec tests

**Detailed Description**

- Bump `spec-tests-version.json` to `v1.7.0-alpha.8` and apply the
matching `specrefs/*` updates.
- Config: `MIN_BUILDER_WITHDRAWABILITY_DELAY` `64 → 8192`; add
`PAYLOAD_DUE_BPS` (mainnet/minimal/types + validator critical params).
- Add Gloas `targetGasLimit` to `PayloadAttributes` (SSZ,
execution-engine `PayloadAttributes`/RPC + serialize/deserialize).
- Rename `ProposerPreferences.gasLimit → targetGasLimit` (alpha.8) and
update the unstable-only consumers not present on the #9375 branch:
`validatorStore.signProposerPreferences`, gossip
`validateExecutionPayloadBid`, and test/event fixtures. The gossip
bid-validation rule keeps strict equality (rename only);
`is_gas_limit_target_compatible` is a separate follow-up.
- `upgradeStateToGloas`: set `latestExecutionPayloadBid.gasLimit` from
the Fulu header and bump the spec-comment URL. The existing
`onboardBuildersFromPendingDeposits` is already spec-equivalent and is
left as-is; the previously-skipped
`fork_invalid_validator_deposit_followed_by_builder_credentials` spec
test is re-enabled and passes.
- `produceBlockBody`: resolve the Gloas payload-attributes
`targetGasLimit` from the `ProposerPreferencesPool` (same `(slot,
dependent_root)` lookup as bid validation), falling back to the parent
payload gas limit when no preferences are pooled. Addresses the #9375
review note that the builder-registration source was incorrect.
- Add `getPayloadDueMs()` to `forkConfig` (spec `get_payload_due_ms`,
`PAYLOAD_DUE_BPS`) and gate `producePayloadAttestationData`'s
`payloadPresent` on the execution payload envelope being seen before
that deadline (uses the envelope's own arrival time). Addresses the
#9375 review note about using `PAYLOAD_DUE_BPS` instead of
`PAYLOAD_ATTESTATION_DUE_BPS`.
- Skip the new `gloas/fork_choice/on_payload_attestation_message` spec
suite (PTC fork choice not yet implemented).


**AI Assistance Disclosure**

Used Claude Code to port and adapt the changes, address the PR review
comments, and run verification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
Co-authored-by: Cayman <caymannava@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

spec-gloas Issues targeting the Glamsterdam spec version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants