once we merge #10772
Summary
The Gloas beacon_attestation_{subnet_id} spec adds payload-status gossip rules that Teku does not fully implement. Today AttestationUtilGloas.validatePayloadStatus enforces only the same-slot rule, so a FULL-payload vote (index == 1) whose execution payload has not been seen is still ACCEPTed and propagated, and a FULL vote for a seen-but-invalid payload is not rejected.
This issue covers the gossip-validation layer only. The fork-choice handling of these attestations (holding the vote, withholding aggregation, re-processing on payload import) is delivered separately by the FULL-payload attestation parking work — see the two-layer note below.
Spec rules (specs/gloas/p2p-interface.md, beacon_attestation_{subnet_id})
[REJECT] attestation.data.index < 2 — ✅ implemented (validateCommitteeIndexValue)
[REJECT] attestation.data.index == 0 if block.slot == attestation.data.slot — ✅ implemented (validatePayloadStatus)
[IGNORE] when index == 1, the execution payload for block has been seen (MAY queue, SHOULD request by-root) — ❌ missing (this issue)
[REJECT] if index == 1, the execution payload for block passes validation — ❌ missing (this issue)
Two layers: where each verdict lives
A FULL-payload attestation for a not-yet-available payload flows through two independent decision points, with two different return types. Understanding the split is the whole point of this issue.
-
Gossip validation — AttestationValidator.validate(...) → InternalValidationResult.
Returned from AttestationManager.addAttestation(...) and used by the gossip topic handler to decide propagation (and peer scoring). Mapping (GossipSubValidationUtil.fromInternalValidationResult):
ACCEPT → gossipsub Valid (propagate)
SAVE_FOR_FUTURE, IGNORE → gossipsub Ignore (do not propagate, no penalty)
REJECT → gossipsub Invalid (do not propagate, penalize)
-
Fork-choice processing — ForkChoice.onAttestation(...) (the attestationProcessor) → AttestationProcessingResult, handled by the switch in AttestationManager.onAttestation(...). This decides what happens in fork choice and how the attestation is parked/aggregated (e.g. UNKNOWN_BLOCK → park for block; UNKNOWN_EXECUTION_PAYLOAD → park for payload).
These compose because AttestationManager.processInternallyValidatedAttestation routes both ACCEPT and SAVE_FOR_FUTURE into onAttestation:
if (code == ACCEPT || code == SAVE_FOR_FUTURE) {
onAttestation(attestation)... // fork-choice processing + parking
}
So fork-choice processing (and therefore parking + reprocess) happens regardless of whether the gossip verdict is ACCEPT or SAVE_FOR_FUTURE; the gossip verdict only changes propagation.
Why SAVE_FOR_FUTURE, not IGNORE
The spec rule is [IGNORE], but Teku must return SAVE_FOR_FUTURE, not the IGNORE code: both map to gossipsub Ignore (no propagation), but IGNORE is dropped before onAttestation runs, so the attestation would never be parked or re-processed. SAVE_FOR_FUTURE is gossipsub-Ignore and still routes into fork-choice processing — i.e. it realises the spec's "IGNORE + MAY queue (and reprocess when the payload is retrieved)".
This mirrors the existing missing-block handling
| Condition |
Gossip verdict (AttestationValidator) |
Fork-choice result (ForkChoice.onAttestation) |
Parking |
| Beacon block unknown |
SAVE_FOR_FUTURE (!isBlockAvailable) |
UNKNOWN_BLOCK |
attestationsWaitingForBlock, replayed on onBlockImported |
| FULL node absent (payload unseen) |
SAVE_FOR_FUTURE (this issue) |
UNKNOWN_EXECUTION_PAYLOAD (parking PR) |
attestationsWaitingForFullPayload, replayed on onExecutionPayloadImported |
The gossip layer just says "not ready, don't propagate"; the fork-choice layer says why (UNKNOWN_BLOCK vs UNKNOWN_EXECUTION_PAYLOAD) and parks in the right pool.
Division of responsibility (what is already done vs this issue)
Three concerns, three mechanisms:
| Concern |
Mechanism |
PR |
| Vote not counted before the FULL node exists |
record vote + resolveVoteNode/ProtoArrayScoreCalculator guard |
parking PR (retained from #10772) |
| Don't aggregate into our blocks; re-process on payload import |
AttestationManager parks on UNKNOWN_EXECUTION_PAYLOAD; onExecutionPayloadImported reprocess; payload requested by-root |
parking PR |
Don't propagate to peers ([IGNORE]); penalize invalid ([REJECT]) |
gossip verdict SAVE_FOR_FUTURE / REJECT in AttestationValidator |
this issue |
Implementation sketch
In AttestationValidator.singleOrAggregateAttestationChecks, after the existing block-availability SAVE_FOR_FUTURE (so the block is known), add a Gloas-gated check. Fork-gated via getFullPayloadVoteHint(index) (no-op pre-Gloas). Determine the FULL-node / payload status from fork choice + the new invalid-payload cache (below):
fullPayloadVote = forkChoiceUtil.getFullPayloadVoteHint(index) // true only for Gloas index==1
if (fullPayloadVote):
if (invalidExecutionPayloads.contains(beaconBlockRoot)): // seen + invalid
return REJECT
if (forkChoiceStrategy.getBlockData(beaconBlockRoot, PAYLOAD_STATUS_FULL).isEmpty()): // not seen / no FULL node
return SAVE_FOR_FUTURE
// FULL node present (seen + valid) -> fall through to normal validation (ACCEPT)
Prerequisite: an invalid-execution-payload cache
The [REJECT] rule needs to distinguish "payload seen and invalid" from "payload not seen yet".
- A shared
invalidBlockRoots cache exists (Map<Bytes32, BlockImportResult>, LimitedMap.createSynchronizedLRU(500) in BeaconChainController, threaded through the block/blob/data-column/execution-payload/payload-attestation gossip validators) — but it tracks invalid blocks, not invalid execution payloads.
- Invalid payloads are currently only debug-dumped + logged (
ForkChoice.reportInvalidExecutionPayload); they are not retained anywhere queryable.
So we need a new invalid-execution-payload cache keyed by beacon_block_root (analogous to invalidBlockRoots, bounded LRU), populated where invalid payloads are reported (ForkChoice.reportInvalidExecutionPayload / the failed-payload-execution path) and consulted by AttestationValidator. (Only required for [REJECT]; the [IGNORE] rule needs only the FULL-node-absent check.)
Acceptance criteria
beacon_attestation gossip returns SAVE_FOR_FUTURE (gossipsub-Ignore) for index == 1 when the block's FULL node / payload has not been seen — so it is not propagated, yet is still parked + reprocessed by the existing fork-choice path.
- A bounded invalid-execution-payload cache keyed by
beacon_block_root.
beacon_attestation gossip returns REJECT for index == 1 when the block's payload was seen and is invalid.
- Pre-Gloas attestations are unaffected (rules gated by milestone /
getFullPayloadVoteHint).
- Tests covering:
SAVE_FOR_FUTURE when payload not seen (and confirm it is parked, not dropped); REJECT on seen-invalid payload; ACCEPT when the FULL node is present.
once we merge #10772
Summary
The Gloas
beacon_attestation_{subnet_id}spec adds payload-status gossip rules that Teku does not fully implement. TodayAttestationUtilGloas.validatePayloadStatusenforces only the same-slot rule, so a FULL-payload vote (index == 1) whose execution payload has not been seen is still ACCEPTed and propagated, and a FULL vote for a seen-but-invalid payload is not rejected.This issue covers the gossip-validation layer only. The fork-choice handling of these attestations (holding the vote, withholding aggregation, re-processing on payload import) is delivered separately by the FULL-payload attestation parking work — see the two-layer note below.
Spec rules (
specs/gloas/p2p-interface.md,beacon_attestation_{subnet_id})[REJECT] attestation.data.index < 2— ✅ implemented (validateCommitteeIndexValue)[REJECT] attestation.data.index == 0 if block.slot == attestation.data.slot— ✅ implemented (validatePayloadStatus)[IGNORE] when index == 1, the execution payload for block has been seen(MAY queue, SHOULD request by-root) — ❌ missing (this issue)[REJECT] if index == 1, the execution payload for block passes validation— ❌ missing (this issue)Two layers: where each verdict lives
A FULL-payload attestation for a not-yet-available payload flows through two independent decision points, with two different return types. Understanding the split is the whole point of this issue.
Gossip validation —
AttestationValidator.validate(...)→InternalValidationResult.Returned from
AttestationManager.addAttestation(...)and used by the gossip topic handler to decide propagation (and peer scoring). Mapping (GossipSubValidationUtil.fromInternalValidationResult):ACCEPT→ gossipsubValid(propagate)SAVE_FOR_FUTURE,IGNORE→ gossipsubIgnore(do not propagate, no penalty)REJECT→ gossipsubInvalid(do not propagate, penalize)Fork-choice processing —
ForkChoice.onAttestation(...)(theattestationProcessor) →AttestationProcessingResult, handled by theswitchinAttestationManager.onAttestation(...). This decides what happens in fork choice and how the attestation is parked/aggregated (e.g.UNKNOWN_BLOCK→ park for block;UNKNOWN_EXECUTION_PAYLOAD→ park for payload).These compose because
AttestationManager.processInternallyValidatedAttestationroutes bothACCEPTandSAVE_FOR_FUTUREintoonAttestation:So fork-choice processing (and therefore parking + reprocess) happens regardless of whether the gossip verdict is
ACCEPTorSAVE_FOR_FUTURE; the gossip verdict only changes propagation.Why
SAVE_FOR_FUTURE, notIGNOREThe spec rule is
[IGNORE], but Teku must returnSAVE_FOR_FUTURE, not theIGNOREcode: both map to gossipsubIgnore(no propagation), butIGNOREis dropped beforeonAttestationruns, so the attestation would never be parked or re-processed.SAVE_FOR_FUTUREis gossipsub-Ignore and still routes into fork-choice processing — i.e. it realises the spec's "IGNORE + MAY queue (and reprocess when the payload is retrieved)".This mirrors the existing missing-block handling
AttestationValidator)ForkChoice.onAttestation)SAVE_FOR_FUTURE(!isBlockAvailable)UNKNOWN_BLOCKattestationsWaitingForBlock, replayed ononBlockImportedSAVE_FOR_FUTURE(this issue)UNKNOWN_EXECUTION_PAYLOAD(parking PR)attestationsWaitingForFullPayload, replayed ononExecutionPayloadImportedThe gossip layer just says "not ready, don't propagate"; the fork-choice layer says why (
UNKNOWN_BLOCKvsUNKNOWN_EXECUTION_PAYLOAD) and parks in the right pool.Division of responsibility (what is already done vs this issue)
Three concerns, three mechanisms:
resolveVoteNode/ProtoArrayScoreCalculatorguardAttestationManagerparks onUNKNOWN_EXECUTION_PAYLOAD;onExecutionPayloadImportedreprocess; payload requested by-root[IGNORE]); penalize invalid ([REJECT])SAVE_FOR_FUTURE/REJECTinAttestationValidatorImplementation sketch
In
AttestationValidator.singleOrAggregateAttestationChecks, after the existing block-availabilitySAVE_FOR_FUTURE(so the block is known), add a Gloas-gated check. Fork-gated viagetFullPayloadVoteHint(index)(no-op pre-Gloas). Determine the FULL-node / payload status from fork choice + the new invalid-payload cache (below):Prerequisite: an invalid-execution-payload cache
The
[REJECT]rule needs to distinguish "payload seen and invalid" from "payload not seen yet".invalidBlockRootscache exists (Map<Bytes32, BlockImportResult>,LimitedMap.createSynchronizedLRU(500)inBeaconChainController, threaded through the block/blob/data-column/execution-payload/payload-attestation gossip validators) — but it tracks invalid blocks, not invalid execution payloads.ForkChoice.reportInvalidExecutionPayload); they are not retained anywhere queryable.So we need a new invalid-execution-payload cache keyed by
beacon_block_root(analogous toinvalidBlockRoots, bounded LRU), populated where invalid payloads are reported (ForkChoice.reportInvalidExecutionPayload/ the failed-payload-execution path) and consulted byAttestationValidator. (Only required for[REJECT]; the[IGNORE]rule needs only the FULL-node-absent check.)Acceptance criteria
beacon_attestationgossip returnsSAVE_FOR_FUTURE(gossipsub-Ignore) forindex == 1when the block's FULL node / payload has not been seen — so it is not propagated, yet is still parked + reprocessed by the existing fork-choice path.beacon_block_root.beacon_attestationgossip returnsREJECTforindex == 1when the block's payload was seen and is invalid.getFullPayloadVoteHint).SAVE_FOR_FUTUREwhen payload not seen (and confirm it is parked, not dropped);REJECTon seen-invalid payload;ACCEPTwhen the FULL node is present.