Skip to content

Derive duty deadlines from Gloas spec - #428

Open
AntiD2ta wants to merge 7 commits into
gloas-attestation-aggregationfrom
gloas-attestation-deadlines
Open

Derive duty deadlines from Gloas spec#428
AntiD2ta wants to merge 7 commits into
gloas-attestation-aggregationfrom
gloas-attestation-deadlines

Conversation

@AntiD2ta

@AntiD2ta AntiD2ta commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • derive post-Gloas duty deadlines from the beacon node's served timing values
  • default the four duty-delay options to 0 so the derivation is actually reachable
  • select the pre-Gloas or Gloas deadlines per duty, from that duty's slot
  • retain legacy timing for pre-Gloas networks and individually missing values
  • read the _GLOAS-suffixed deadlines after the fork, and fall back to the Gloas values

The bug: the derived deadlines were unreachable

The controller derives its duty deadlines from the chain specification, but only when the
corresponding option is unset:

if p.maxAttestationDelay == 0 {
    p.maxAttestationDelay = attestationDue
}

main.go made that condition permanently false. It declared hardcoded defaults and then passed
them unconditionally:

viper.SetDefault("controller.max-attestation-delay", 4*time.Second)
viper.SetDefault("controller.attestation-aggregation-delay", 8*time.Second)
// ...
standardcontroller.WithMaxAttestationDelay(viper.GetDuration("controller.max-attestation-delay")),

Because viper always yields a non-zero duration, the fallback never ran and every spec-derived
deadline was computed and discarded. The four values were fixed at 4s/8s/4s/8s for the lifetime of
the process regardless of what the chain served — so a beacon node serving ATTESTATION_DUE_BPS, or
a SLOT_DURATION_MS shorter than SECONDS_PER_SLOT, had no effect.

This was not Gloas-specific. Any chain whose slot is shorter than 12 seconds — the minimal preset
used by local devnets — already received deadlines derived from a 12-second slot, putting the 8s
aggregation deadline after the end of a 6s slot.

The bug: the fork decision was frozen at startup

The choice between pre-Gloas and Gloas deadlines was made once, during construction:

gloasActive := parameters.chainTimeService.CurrentEpoch() >= parameters.chainTimeService.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")

HardForkEpoch is a constant and is fine to read once, but CurrentEpoch() is not. Freezing the
comparison gives the result a shelf life of one epoch while the process runs for months, so a vouch
started before GLOAS_FORK_EPOCH would keep the pre-Gloas deadlines for its entire lifetime.

This had no observable effect while the derived values were being discarded. Making the derivation
reachable arms it, so the two must be fixed together.

The fix

Reachability. Default the four options to 0, the sentinel the controller already treats as
"derive from the chain specification". This is behaviour-preserving on mainnet: the pre-Gloas
derivation is slotDuration/3 and slotDuration*2/3, which on a 12-second slot yields exactly the
4s and 8s that were previously hardcoded — those constants were the derivation, written out by hand.
A dedicated test case pins that equivalence so the two cannot drift apart silently.

Fork selection. Both deadline sets are derived at construction and stored; which one applies is
decided per duty from that duty's slot:

func (s *Service) timingsForSlot(slot phase0.Slot) *dutyTimings {
    if s.chainTimeService.SlotToEpoch(slot) >= s.gloasForkEpoch {
        return &s.gloasTimings
    }

    return &s.preGloasTimings
}

Keying on the duty's slot rather than on the current epoch matters at the boundary: duties are
scheduled up to an epoch ahead, so during the epoch before the fork the controller is scheduling
duties for the fork epoch. A predicate on CurrentEpoch() would hand those post-fork duties the
pre-fork deadlines. This mirrors the existing fork gate in the proposer, which compares
SlotToEpoch(duty.Slot()) against the fork epoch for the same reason.

Both sets are immutable after construction, so the selector needs no synchronisation — the four
delay fields were previously written once before the scheduling goroutines start, and that property
is preserved.

gloasForkEpoch is obtained through gloasDetails, following the existing electraDetails and
bellatrixDetails precedent: a chain that does not carry GLOAS_FORK_EPOCH yields the far-future
sentinel, the comparison is never true, and the pre-Gloas deadlines apply forever. Vouch starts and
logs rather than refusing, as it does for every other fork.

Operator overrides keep their existing meaning and are applied to both sets: an explicit value is
absolute and applies on both sides of the fork. docs/configuration.md documents this, and notes
that an explicit value valid today can schedule duties past the end of the slot on a future fork.

The bug: the Gloas deadlines fell back to the pre-Gloas ones

The Gloas derivation looked up the _GLOAS-suffixed key, then the unsuffixed key, then a ratio:

bps, ok := spec[name+"_GLOAS"].(uint64)
if !ok {
    bps, ok = spec[name].(uint64)
}

Both fallbacks are the deadlines Gloas moves away from. The unsuffixed keys hold the pre-Gloas
values and keep being served after the fork, and the ratios were slotDuration/3 and
slotDuration*2/3, which are the pre-Gloas 3333 and 6667 basis points. So a node that served an
incomplete set produced post-fork duties on pre-fork timings — the same defect this PR exists to fix,
reintroduced one level down.

Against glamsterdam-devnet-7, which serves ATTESTATION_DUE_BPS_GLOAS but none of the other three
suffixed keys, that meant:

Duty Produced Gloas requires
Attestation 3s 3s
Aggregate 8.0004s 6s
Sync committee message 3.9996s 3s
Sync committee contribution 8.0004s 6s

An 8.0004s aggregation lands after the 6s payload reveal, in a slot whose anatomy is propose at 0s,
attest at 3s, reveal at 6s, and vote on the payload at 9s.

The fix. The Gloas arm reads the exact _GLOAS key and no longer falls through to the unsuffixed
one, and its fallbacks are the Gloas values — a quarter and a half of the slot, being 2500 and
5000 basis points. That matches the convention the payload deadlines already use.

glamsterdam-devnet-8 serves all six suffixed keys and was therefore already correct; what changes
is the behaviour of every node that does not.

Also in this change

  • Fallback deadlines are now fractions of the Gloas slot duration rather than of SECONDS_PER_SLOT.
    Previously the ratio defaults were computed before the SLOT_DURATION_MS override was read, so a
    node serving SLOT_DURATION_MS=6000 without the *_DUE_BPS values produced an 8-second deadline
    inside a 6-second slot.
  • Served basis-point values are range-checked. ATTESTATION_DUE_BPS: 0 previously produced a
    slot-start deadline (attesting before a block can arrive) and a value above 10000 produced a
    deadline past the end of the slot, both silently. Out-of-range values now fall back to the ratio
    default.
  • The timing tests take their expected values from the specification rather than from the formula
    the derivation itself uses. TestGloasSpecConformance pins them to the configuration served by
    beacon.glamsterdam-devnet-8.ethpandaops.io, and a devnet-7 case covers the partial key set. The
    previous table derived every expectation from the same arithmetic as the code, so it could only
    demonstrate that the code agreed with itself.
  • createElectraAttestations guarded its validator-index lookup with len(validatorIndices) < i, an
    off-by-one that panics when i == len(validatorIndices). It now matches the correct
    i >= len(validatorIndices) form.
  • scheduleAttestationAggregations is split out of the attester scheduling path. This is a pure
    extraction with no behaviour change, keeping the enclosing function within the complexity limit
    now that the aggregation deadline is looked up per slot.
  • The copyright range on services/controller/standard/synccommitteemessenger.go is refreshed, as
    the custom lint requires for a file this change modifies.

Known limitation

services/chaintime/standard derives its slot duration solely from SECONDS_PER_SLOT and does not
read SLOT_DURATION_MS. Every deadline here is applied as an offset from StartOfSlot(slot), so on
a chain where the two values diverge the slot boundaries themselves would be wrong and correct
in-slot offsets would not be sufficient. That is out of scope here and needs resolving before any
network ships a Gloas slot duration that differs from SECONDS_PER_SLOT.

Relatedly, SECONDS_PER_SLOT has been dropped from the devnet-8 config.yaml and from
configs/mainnet.yaml at v1.7.0-alpha.13. Live nodes still serve it through
/eth/v1/config/spec, and vouch reads only that key — parameters.slotDuration returns an error
without it, so vouch would refuse to start against a node that stopped serving it. That is a
fork-independent dependency on a compatibility alias and is not addressed here.

One residual restart requirement remains, shared with every other fork vouch handles: a beacon node
that begins serving GLOAS_FORK_EPOCH only after vouch has started requires a vouch restart, because
the fork schedule is read once at construction and the spec is cached for the life of the process.

Reviewer note

Most of the diff in services/controller/standard/service.go is gofmt realignment: removing
syncCommitteeAggregationDelay, the longest field name in the Service struct, shifts the
alignment column for every other field. Reviewing that file with git show -w reduces it to the 30
inserted and 8 deleted lines that actually change behaviour.

Validation

  • go build ./... — clean.
  • gosilent test ./... — 989 tests, all pass.
  • ./custom-gcl run --max-issues-per-linter=0 --max-same-issues=0 — 14 issues, all pre-existing and
    outside the changed lines.

@AntiD2ta AntiD2ta self-assigned this Aug 18, 2026
@AntiD2ta
AntiD2ta marked this pull request as ready for review August 19, 2026 08:55
@AntiD2ta
AntiD2ta requested a review from Bez625 August 19, 2026 08:59
The controller derives its duty deadlines from the chain specification only when the
corresponding option is unset, but main.go declared hardcoded defaults of 4s/8s and passed
them unconditionally. The zero check was therefore never true, and every spec-derived
deadline was computed and discarded: a beacon node serving ATTESTATION_DUE_BPS, or a
SLOT_DURATION_MS shorter than SECONDS_PER_SLOT, had no effect. This also predates Gloas —
a minimal-preset chain with a 6s slot already got an 8s aggregation deadline.

Default those four options to 0, the sentinel that selects derivation. This preserves
mainnet behaviour exactly: the pre-Gloas derivation of slotDuration/3 and slotDuration*2/3
yields 4s and 8s on a 12-second slot, so the hardcoded constants were the derivation
written out by hand. A test case pins that equivalence.

Making the derivation reachable arms a second defect. The choice between the pre-Gloas and
Gloas deadlines was frozen at construction, from CurrentEpoch() >= HardForkEpoch(...). The
fork epoch is constant and fine to read once; the current epoch is not, so the comparison
had a shelf life of one epoch while the process runs for months. Derive both deadline sets
at construction and select between them per duty, from that duty's slot.

The predicate keys on the duty's slot rather than the current epoch because duties are
scheduled up to an epoch ahead: during the epoch before the fork the controller schedules
duties for the fork epoch, and a current-epoch predicate would give those the pre-fork
deadlines. This matches the proposer's existing fork gate. Both sets are immutable after
construction, so the selector needs no synchronisation.

Obtain the Gloas fork epoch through gloasDetails, following the electraDetails precedent:
a chain without GLOAS_FORK_EPOCH yields the far-future sentinel and stays on the pre-Gloas
deadlines rather than failing to start. Operator overrides keep their meaning and apply to
both sets, as documented.

Alongside this:

- derive fallback deadlines from the Gloas slot duration rather than SECONDS_PER_SLOT; they
  were previously computed before the SLOT_DURATION_MS override was read, so a node serving
  SLOT_DURATION_MS=6000 without the *_DUE_BPS values produced an 8s deadline in a 6s slot
- range-check served basis points; 0 produced a slot-start deadline and a value above 10000
  produced one past the end of the slot, both silently
- exercise the plain *_DUE_BPS key names in tests, not only the _GLOAS-suffixed variants, so
  the branch that runs against a real beacon node is covered
- fix an off-by-one in createElectraAttestations, where len(validatorIndices) < i let
  i == len(validatorIndices) through and panicked
The duty timing selection changed this file, so the custom lint requires its copyright
range to cover the current year.
The duty timing options described a third and two thirds of the slot duration as
the derived default.  That holds before Gloas, but from Gloas onwards those
fractions are only the fallback used when the chain serves no basis-point value
for the duty; the default is otherwise the served deadline, applied to
SLOT_DURATION_MS rather than to SECONDS_PER_SLOT.

Name the spec value behind each option, and state in the shared preamble that
the two derivations are selected per duty from that duty's slot.
The Gloas derivation read the unsuffixed spec keys as a fallback when the
_GLOAS-suffixed key was absent, and fell back to the pre-Gloas fractions when
neither was served.  Both fallbacks are the deadlines that Gloas moves away
from, so a node that does not serve the full set produced post-fork duties on
pre-fork timings.  Against glamsterdam-devnet-7, which serves
ATTESTATION_DUE_BPS_GLOAS but none of the other three suffixed keys, that meant
aggregating at 8.0004s and generating sync committee messages at 3.9996s where
Gloas requires 6s and 3s; the 8.0004s aggregation lands after the 6s payload
reveal.

Read the exact _GLOAS key for the four deadlines Gloas redefines, and make the
fallbacks the Gloas values (a quarter and a half of the slot, being 2500 and
5000 basis points) rather than the pre-Gloas third and two thirds.  This matches
the convention the payload deadlines already use.

Pin the derived values to the specification rather than to the formula, with a
conformance test built from the configuration that
beacon.glamsterdam-devnet-8.ethpandaops.io serves, and a regression test for the
devnet-7 key set.  Devnet-8 serves all six suffixed keys, so it was already
correct; the fallback path is what changes.

Record the pre-Gloas and Gloas values alongside each option in main.go and in
docs/configuration.md.
@AntiD2ta
AntiD2ta force-pushed the gloas-attestation-deadlines branch from 788dfcb to 460197f Compare August 21, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant