diff --git a/core/consensus/timer/roundtimer.go b/core/consensus/timer/roundtimer.go index 2799ba20b..166784462 100644 --- a/core/consensus/timer/roundtimer.go +++ b/core/consensus/timer/roundtimer.go @@ -43,9 +43,9 @@ func selectTimer(duty core.Duty, gloas bool) timerDef { var def timerDef switch { - case duty.Type == core.DutyAttester && gloas: - // From gloas, attester duties use the eager ahead split timer regardless of the feature set, - // since round end times must be identical across the cluster. + case duty.Type == core.DutyAttester && gloas, duty.Type == core.DutyPayloadAttestation: + // From gloas, attester and payload attestation duties use the eager ahead split timer regardless + // of the feature set, since round end times must be identical across the cluster. // TODO(post-gloas): make this the default for attester duties without the gloas check, removing // the fork schedule from GetRoundTimerFunc. def = eagerAheadSplitTimer() diff --git a/core/consensus/timer/roundtimer_internal_test.go b/core/consensus/timer/roundtimer_internal_test.go index 52ad58ca5..2647ad6ba 100644 --- a/core/consensus/timer/roundtimer_internal_test.go +++ b/core/consensus/timer/roundtimer_internal_test.go @@ -310,18 +310,21 @@ func TestTimerSelection(t *testing.T) { var ( genesisTime = time.Unix(1_000_000, 0) slotStart = genesisTime.Add(slot * slotDuration) - // timingFor returns the slot timing with attestations due at the offset into the slot, and - // proposals at its start. + // timingFor returns the slot timing with attestations due at the offset into the slot, payload + // attestations 3/4 into the slot, and proposals at its start. timingFor = func(attestationDue time.Duration) slotTiming { return slotTiming{ genesisTime: genesisTime, slotDuration: slotDuration, slotOffset: func(duty core.Duty) time.Duration { - if duty.Type == core.DutyAttester { + switch duty.Type { + case core.DutyAttester: return attestationDue + case core.DutyPayloadAttestation: + return 9 * time.Second + default: + return 0 } - - return 0 }, } } @@ -381,6 +384,24 @@ func TestTimerSelection(t *testing.T) { wantExtensions: []string{"double_round_on_rearm_min_1s"}, want: []roundArming{{ms(4000), ms(5000)}, {ms(5000), ms(6000)}, {ms(6000), ms(7000)}, {ms(8000), ms(10000)}, {ms(11000), ms(14000)}}, }, + { + name: "payload attestation, defaults", + duty: core.NewPayloadAttestationDuty(slot), + gloas: true, + wantType: TimerEagerAheadSplit, + wantExtensions: []string{"double_round_on_rearm_min_1s"}, + want: []roundArming{{ms(10000), ms(11000)}, {ms(11000), ms(12000)}, {ms(12000), ms(13000)}, {ms(14000), ms(16000)}, {ms(17000), ms(20000)}}, + }, + { + name: "payload attestation, eager double linear disabled and linear enabled", + enable: []featureset.Feature{featureset.Linear}, + disable: []featureset.Feature{featureset.EagerDoubleLinear}, + duty: core.NewPayloadAttestationDuty(slot), + gloas: true, + wantType: TimerEagerAheadSplit, + wantExtensions: []string{"double_round_on_rearm_min_1s"}, + want: []roundArming{{ms(10000), ms(11000)}, {ms(11000), ms(12000)}, {ms(12000), ms(13000)}, {ms(14000), ms(16000)}, {ms(17000), ms(20000)}}, + }, { name: "gloas proposer, defaults", duty: core.NewProposerDuty(slot), diff --git a/core/fetcher/fetcher.go b/core/fetcher/fetcher.go index 29b5316c8..ea2dca964 100644 --- a/core/fetcher/fetcher.go +++ b/core/fetcher/fetcher.go @@ -217,8 +217,7 @@ func (f *Fetcher) fetchGloasAttesterData(ctx context.Context, slot uint64, defSe // Fall back to fetching from all beacon nodes at the attestation due time, if no block arrived in // the block proposal interval. - slotStart := f.genesisTime.Add(f.slotDuration * time.Duration(slot)) - dueTime := slotStart.Add(f.slotOffsetFunc(core.NewAttesterDuty(slot))) + dueTime := f.dueTime(core.NewAttesterDuty(slot)) timer := f.clock.NewTimer(dueTime.Sub(f.clock.Now())) defer timer.Stop() @@ -832,45 +831,83 @@ func (f *Fetcher) forkActive(fork eth2wrap.Fork, slot uint64) bool { return f.forkSchedule().Active(fork, eth2p0.Epoch(slot/f.slotsPerEpoch)) } -// fetchPayloadAttestationData returns the fetched payload attestation data for the slot. -// The data is per-slot, not per-validator, so all payload timeliness committee members -// in the arg set attest to the same data. +// dueTime returns the time the duty's data is due, its slot offset into its slot. +func (f *Fetcher) dueTime(duty core.Duty) time.Time { + slotStart := f.genesisTime.Add(f.slotDuration * time.Duration(duty.Slot)) + + return slotStart.Add(f.slotOffsetFunc(duty)) +} + +// fetchPayloadAttestationData returns the payload attestation data of the slot. The data is per-slot, +// not per-validator, so all payload timeliness committee members in the arg set attest to the same data. +// +// The spec has members attest as soon as the payload and its blob data are seen, but only report +// either as missing once the payload attestation due time elapsed, since they may still arrive. So +// data that doesn't report both, or a failed fetch, is fetched again at the due time. func (f *Fetcher) fetchPayloadAttestationData(ctx context.Context, slot uint64, defSet core.DutyDefinitionSet) (core.UnsignedDataSet, error) { + dueTime := f.dueTime(core.NewPayloadAttestationDuty(slot)) + + if f.clock.Now().Before(dueTime) { + data, err := f.fetchPayloadAttestationDataOnce(ctx, slot) + if err != nil { + log.Debug(ctx, "Payload attestation data fetch failed, fetching again at due time", + z.U64("slot", slot), z.Any("due_time", dueTime), z.Err(err)) + } else if data.Gloas != nil && data.Gloas.PayloadPresent && data.Gloas.BlobDataAvailable { + return payloadAttestationSet(data, defSet), nil + } else { + log.Debug(ctx, "Payload or blob data not seen yet, fetching payload attestation data again at due time", + z.U64("slot", slot), z.Bool("no_block", data.NoPayload), z.Any("due_time", dueTime)) + } + + timer := f.clock.NewTimer(dueTime.Sub(f.clock.Now())) + defer timer.Stop() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.Chan(): + } + } + + data, err := f.fetchPayloadAttestationDataOnce(ctx, slot) + if err != nil { + return nil, err + } + + return payloadAttestationSet(data, defSet), nil +} + +// fetchPayloadAttestationDataOnce fetches the payload attestation data of the slot from the beacon node, +// returning the no payload data if it has not seen a block for the slot. +func (f *Fetcher) fetchPayloadAttestationDataOnce(ctx context.Context, slot uint64) (core.VersionedPayloadAttestationData, error) { opts := ð2api.PayloadAttestationDataOpts{ Slot: eth2p0.Slot(slot), } - var data core.VersionedPayloadAttestationData - eth2Resp, err := f.eth2Cl.PayloadAttestationData(ctx, opts) if errors.Is(err, eth2client.ErrNoPayloadAttestationData) { // The beacon node has not seen a block for the slot. Propose the no payload data for it, // so the cluster agrees on the no-block outcome like on any other value. - log.Debug(ctx, "No payload seen for slot's block", z.U64("slot", slot)) - version := f.forkSchedule().DataVersion(eth2p0.Epoch(slot / f.slotsPerEpoch)) - data, err = core.NewNoPayloadAttestationData(version, eth2p0.Slot(slot)) - if err != nil { - return nil, err - } + return core.NewNoPayloadAttestationData(version, eth2p0.Slot(slot)) } else if err != nil { - return nil, err + return core.VersionedPayloadAttestationData{}, err } else if eth2Resp.Data == nil { - return nil, errors.New("payload attestation data is nil") - } else { - data, err = core.NewVersionedPayloadAttestationData(eth2Resp.Data) - if err != nil { - return nil, err - } + return core.VersionedPayloadAttestationData{}, errors.New("payload attestation data is nil") } + return core.NewVersionedPayloadAttestationData(eth2Resp.Data) +} + +// payloadAttestationSet returns the unsigned data set attesting the data for each validator of the set. +func payloadAttestationSet(data core.VersionedPayloadAttestationData, defSet core.DutyDefinitionSet) core.UnsignedDataSet { resp := make(core.UnsignedDataSet) for pubkey := range defSet { resp[pubkey] = data } - return resp, nil + return resp } // fetchContributionData fetches the sync committee contribution data. diff --git a/core/fetcher/fetcher_internal_test.go b/core/fetcher/fetcher_internal_test.go index 1eac7128b..2ad06a367 100644 --- a/core/fetcher/fetcher_internal_test.go +++ b/core/fetcher/fetcher_internal_test.go @@ -7,9 +7,11 @@ import ( "fmt" "math/big" "strings" + "sync/atomic" "testing" "time" + eth2client "github.com/attestantio/go-eth2-client" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2spec "github.com/attestantio/go-eth2-client/spec" @@ -334,6 +336,183 @@ func (c metadataClient) EPBSProposal(ctx context.Context, opts *eth2api.EPBSProp return resp, nil } +func TestFetchPayloadAttestationDue(t *testing.T) { + const slot = 1 + + var ( + t0 = time.Unix(1_000_000, 0) + slotStart = t0.Add(12 * time.Second) + payloadDue = slotStart.Add(6 * time.Second) // The duty starts once the payload is due, half way into the slot. + dueTime = slotStart.Add(9 * time.Second) // Payload attestations are due 3/4 into the slot. + errFetch = errors.New("payload attestation data not yet final") + pubkey = testutil.RandomCorePubKey(t) + duty = core.NewPayloadAttestationDuty(slot) + defSet = core.DutyDefinitionSet{pubkey: core.NewPTCDefinition(ð2v1.PTCDuty{Slot: slot, ValidatorIndex: 2})} + root = eth2p0.Root{0x01} + seen = &gloas.PayloadAttestationData{BeaconBlockRoot: root, Slot: slot, PayloadPresent: true, BlobDataAvailable: true} + blobMissing = &gloas.PayloadAttestationData{BeaconBlockRoot: root, Slot: slot, PayloadPresent: true} + ) + + // response is a beacon node response to a payload attestation data request. + type response struct { + data *gloas.PayloadAttestationData + err error + } + + // setup returns a gloas fetcher at the time into the slot whose beacon node returns the responses + // in order, along with a channel of fetched data sets and the number of beacon node calls. + setup := func(t *testing.T, now time.Time, responses ...response) (*Fetcher, *clockwork.FakeClock, <-chan core.UnsignedDataSet, *atomic.Int64) { + t.Helper() + + bmock, err := beaconmock.New(t.Context(), beaconmock.WithGenesisTime(t0), beaconmock.WithSlotsPerEpoch(1)) + require.NoError(t, err) + + var calls atomic.Int64 + + bmock.PayloadAttestationDataFunc = func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + resp := responses[calls.Add(1)-1] + if resp.err != nil { + return nil, resp.err + } + + return ð2spec.VersionedPayloadAttestationData{Version: eth2spec.DataVersionGloas, Gloas: resp.data}, nil + } + + fetch, err := New(t.Context(), bmock, nil, false, &GraffitiBuilder{}, + func() eth2wrap.ForkForkSchedule { return eth2wrap.ForkForkSchedule{eth2wrap.Gloas: {Epoch: 0}} }, 1, &gloas.BuilderConfig{}, false) + require.NoError(t, err) + + clock := clockwork.NewFakeClockAt(now) + fetch.clock = clock + + fetched := make(chan core.UnsignedDataSet, 1) + + fetch.Subscribe(func(_ context.Context, _ core.Duty, set core.UnsignedDataSet) error { + fetched <- set + return nil + }) + + return fetch, clock, fetched, &calls + } + + // fetchAsync runs Fetch in the background, returning its error channel. + fetchAsync := func(ctx context.Context, fetch *Fetcher) <-chan error { + errCh := make(chan error, 1) + + go func() { + errCh <- fetch.Fetch(ctx, duty, defSet) + }() + + return errCh + } + + requireFetched := func(t *testing.T, fetched <-chan core.UnsignedDataSet, errCh <-chan error, expected *gloas.PayloadAttestationData) { + t.Helper() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(time.Second): + require.Fail(t, "payload attestation data not fetched") + } + + set := <-fetched + require.Equal(t, expected, set[pubkey].(core.VersionedPayloadAttestationData).Gloas) + } + + // requireRefetchedAtDue asserts that the fetcher waits for the due time before fetching again. + requireRefetchedAtDue := func(t *testing.T, clock *clockwork.FakeClock, fetched <-chan core.UnsignedDataSet) { + t.Helper() + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(dueTime.Sub(payloadDue) - time.Millisecond) + + select { + case <-fetched: + require.Fail(t, "fetched again before the due time") + case <-time.After(10 * time.Millisecond): + } + + clock.Advance(time.Millisecond) + } + + t.Run("payload and blob data seen", func(t *testing.T) { + fetch, _, fetched, calls := setup(t, payloadDue, response{data: seen}) + + requireFetched(t, fetched, fetchAsync(t.Context(), fetch), seen) + require.EqualValues(t, 1, calls.Load()) + }) + + t.Run("blob data seen by the due time", func(t *testing.T) { + fetch, clock, fetched, calls := setup(t, payloadDue, response{data: blobMissing}, response{data: seen}) + + errCh := fetchAsync(t.Context(), fetch) + + requireRefetchedAtDue(t, clock, fetched) + + requireFetched(t, fetched, errCh, seen) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("blob data missing at the due time", func(t *testing.T) { + fetch, clock, fetched, calls := setup(t, payloadDue, response{data: blobMissing}, response{data: blobMissing}) + + errCh := fetchAsync(t.Context(), fetch) + + requireRefetchedAtDue(t, clock, fetched) + + requireFetched(t, fetched, errCh, blobMissing) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("block seen by the due time", func(t *testing.T) { + late := &gloas.PayloadAttestationData{BeaconBlockRoot: root, Slot: slot} + fetch, clock, fetched, calls := setup(t, payloadDue, response{err: eth2client.ErrNoPayloadAttestationData}, response{data: late}) + + errCh := fetchAsync(t.Context(), fetch) + + requireRefetchedAtDue(t, clock, fetched) + + requireFetched(t, fetched, errCh, late) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("fetch error before the due time", func(t *testing.T) { + fetch, clock, fetched, calls := setup(t, payloadDue, response{err: errFetch}, response{data: blobMissing}) + + errCh := fetchAsync(t.Context(), fetch) + + requireRefetchedAtDue(t, clock, fetched) + + requireFetched(t, fetched, errCh, blobMissing) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("fetch error at the due time", func(t *testing.T) { + fetch, clock, _, _ := setup(t, payloadDue, response{err: errFetch}, response{err: errFetch}) + + errCh := fetchAsync(t.Context(), fetch) + + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(dueTime.Sub(payloadDue)) + + select { + case err := <-errCh: + require.ErrorContains(t, err, "not yet final") + case <-time.After(time.Second): + require.Fail(t, "fetch did not return") + } + }) + + t.Run("due time already passed", func(t *testing.T) { + // Fetch starts after the due time, e.g. upon a retry, and fetches once. + fetch, _, fetched, calls := setup(t, dueTime.Add(time.Second), response{data: blobMissing}) + + requireFetched(t, fetched, fetchAsync(t.Context(), fetch), blobMissing) + require.EqualValues(t, 1, calls.Load()) + }) +} + func TestFetchEPBSBuilderSource(t *testing.T) { const ( slot = 1 diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index e0e6aee9c..5ad7b0b0b 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -384,9 +384,6 @@ func (s *Scheduler) scheduleSlot(ctx context.Context, slot core.Slot) { go func(duty core.Duty, defSet core.DutyDefinitionSet) { defer span.End() - // From gloas the fetcher waits for the block itself, so attester duties are triggered at - // the start of the block proposal interval, the start of the slot, for consensus to start - // one interval ahead of the attestation due time. gloasAttester := duty.Type == core.DutyAttester && s.forkSchedule().Active(eth2wrap.Gloas, eth2p0.Epoch(slot.Epoch())) // Special handling for attester duties when FetchAttOnBlock features are enabled @@ -396,7 +393,7 @@ func (s *Scheduler) scheduleSlot(ctx context.Context, slot core.Slot) { } s.eventTriggeredAttestations.Store(slot.Slot, true) - } else if !gloasAttester && !delaySlotOffset(dutyCtx, slot, duty, s.delayFunc, s.slotOffsetFunc) { + } else if !delaySlotOffset(dutyCtx, slot, duty, s.delayFunc, s.triggerOffset(slot, duty)) { return // context cancelled } @@ -424,10 +421,28 @@ func (s *Scheduler) scheduleSlot(ctx context.Context, slot core.Slot) { } } -// delaySlotOffset blocks until the slot offset for the duty has been reached and return true. +// triggerOffset returns the offset into the slot at which the duty is triggered, its slot offset. +// Duties the fetcher waits for data of are triggered an interval ahead of their due time instead, for +// consensus to start meanwhile: from gloas attester duties at the start of the slot, as the fetcher +// waits for the block itself, and payload attestation duties once the payload is due, as the fetcher +// waits for the payload's blob data. +func (s *Scheduler) triggerOffset(slot core.Slot, duty core.Duty) time.Duration { + switch duty.Type { + case core.DutyAttester: + if s.forkSchedule().Active(eth2wrap.Gloas, eth2p0.Epoch(slot.Epoch())) { + return 0 + } + case core.DutyPayloadAttestation: + return s.slotOffsetFunc(core.Duty{Slot: duty.Slot, Type: core.DutyExecutionPayloadEnvelope}) + default: + } + + return s.slotOffsetFunc(duty) +} + +// delaySlotOffset blocks until the offset into the slot has been reached and return true. // It returns false if the context is cancelled. -func delaySlotOffset(ctx context.Context, slot core.Slot, duty core.Duty, delayFunc delayFunc, slotOffsetFunc core.SlotOffsetFunc) bool { - offset := slotOffsetFunc(duty) +func delaySlotOffset(ctx context.Context, slot core.Slot, duty core.Duty, delayFunc delayFunc, offset time.Duration) bool { if offset == 0 { // Duty is triggered at the start of the slot. return true diff --git a/core/scheduler/scheduler_internal_test.go b/core/scheduler/scheduler_internal_test.go index a6de31009..853523582 100644 --- a/core/scheduler/scheduler_internal_test.go +++ b/core/scheduler/scheduler_internal_test.go @@ -52,9 +52,7 @@ func TestDelaySlotOffset(t *testing.T) { return resp } - offsetFunc := func(core.Duty) time.Duration { return test.offset } - - require.True(t, delaySlotOffset(t.Context(), slot, core.NewAttesterDuty(slot.Slot), delayFunc, offsetFunc)) + require.True(t, delaySlotOffset(t.Context(), slot, core.NewAttesterDuty(slot.Slot), delayFunc, test.offset)) if !test.expectDelay { require.Empty(t, delayed) @@ -192,6 +190,34 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { return sched, schedVals } +// TestTriggerOffset asserts that gloas attester duties are triggered at the start of the slot and +// payload attestation duties once the payload is due, ahead of their own due times, while other +// duties are triggered at their slot offset. +func TestTriggerOffset(t *testing.T) { + const gloasEpoch = 2 + + offsets := map[core.DutyType]time.Duration{ + core.DutyAttester: 3 * time.Second, + core.DutyExecutionPayloadEnvelope: 6 * time.Second, + core.DutyPayloadAttestation: 9 * time.Second, + } + + sched := &Scheduler{ + slotOffsetFunc: func(duty core.Duty) time.Duration { return offsets[duty.Type] }, + forkSchedule: func() eth2wrap.ForkForkSchedule { + return eth2wrap.ForkForkSchedule{eth2wrap.Gloas: {Epoch: gloasEpoch}} + }, + } + + preGloas := core.Slot{Slot: 1, SlotsPerEpoch: 32} + gloas := core.Slot{Slot: gloasEpoch * 32, SlotsPerEpoch: 32} + + require.Equal(t, 3*time.Second, sched.triggerOffset(preGloas, core.NewAttesterDuty(preGloas.Slot))) + require.Equal(t, time.Duration(0), sched.triggerOffset(gloas, core.NewAttesterDuty(gloas.Slot))) + require.Equal(t, 6*time.Second, sched.triggerOffset(gloas, core.NewPayloadAttestationDuty(gloas.Slot))) + require.Equal(t, 6*time.Second, sched.triggerOffset(gloas, core.Duty{Slot: gloas.Slot, Type: core.DutyExecutionPayloadEnvelope})) +} + func TestResolveAttDuties(t *testing.T) { sched, schedVals := setupScheduler(t) diff --git a/core/slotoffset.go b/core/slotoffset.go index 0447f58c8..94c5d680d 100644 --- a/core/slotoffset.go +++ b/core/slotoffset.go @@ -44,8 +44,8 @@ func newSlotOffsetFunc(slotDuration time.Duration, slotsPerEpoch uint64, timing DutyAggregator: timing.Aggregate, DutySyncMessage: timing.SyncMessage, DutySyncContribution: timing.Contribution, - DutyPayloadAttestation: timing.Payload, - // A self-built payload envelope is due at the same payload deadline the PTC attests against. + DutyPayloadAttestation: timing.PayloadAttestation, + // A self-built payload envelope is due at the payload deadline the PTC attests its timeliness against. DutyExecutionPayloadEnvelope: timing.Payload, } diff --git a/core/slotoffset_internal_test.go b/core/slotoffset_internal_test.go index 38a6bbb0a..e47a4118c 100644 --- a/core/slotoffset_internal_test.go +++ b/core/slotoffset_internal_test.go @@ -37,10 +37,9 @@ func TestSlotOffsetPayloadAttestation(t *testing.T) { const gloasSlot = 64 * 16 - // The payload attestation duty is triggered when the builder's payload is due (1/2 into the - // slot), not at its own deadline of 3/4 into the slot, so that consensus, partial signature - // exchange and submission can complete before the deadline. - require.Equal(t, 6*time.Second, offsetFunc(Duty{Slot: gloasSlot, Type: DutyPayloadAttestation})) + // Payload attestations are due 3/4 into the slot. Note the scheduler triggers the duty an interval + // ahead, once the payload is due. + require.Equal(t, 9*time.Second, offsetFunc(Duty{Slot: gloasSlot, Type: DutyPayloadAttestation})) // The duty doesn't exist before the gloas fork. require.Zero(t, offsetFunc(Duty{Slot: gloasSlot - 1, Type: DutyPayloadAttestation})) @@ -51,8 +50,8 @@ func TestSlotOffsetExecutionPayloadEnvelope(t *testing.T) { const gloasSlot = 64 * 16 - // The self-built payload envelope is due at the payload deadline (1/2 into the slot), the same - // offset the payload attestation anchors to, so the bcast delay reads as margin-to-deadline. + // The self-built payload envelope is due at the payload deadline (1/2 into the slot), so the + // bcast delay reads as margin-to-deadline. require.Equal(t, 6*time.Second, offsetFunc(Duty{Slot: gloasSlot, Type: DutyExecutionPayloadEnvelope})) // The duty doesn't exist before the gloas fork. diff --git a/docs/consensus.md b/docs/consensus.md index 6ce18e4c4..7f094b525 100644 --- a/docs/consensus.md +++ b/docs/consensus.md @@ -74,7 +74,7 @@ A timer is a named configuration of round timing, defined by: Timers are selected per duty by the fork and feature set flags: -- `eager_ahead_split` is used for attester duties from the gloas fork, regardless of feature set flags, since round end times must be identical across the cluster (see [Gloas attester timer](#gloas-attester-timer)). +- `eager_ahead_split` is used for attester duties from the gloas fork and for payload attestation duties, regardless of feature set flags, since round end times must be identical across the cluster (see [Gloas attester and payload attestation timer](#gloas-attester-and-payload-attestation-timer)). - `eager_dlinear` is the default, enabled by the stable `eager_double_linear` feature. - `inc` is used when `eager_double_linear` is disabled (`--feature-set-disable "eager_double_linear"`). - `linear` is used for proposer duties when the alpha `linear` feature is enabled (`--feature-set-enable "linear"`), taking precedence over the other timers. Its first round includes fetching the proposal. Peers already have their proposal in later rounds, which start shorter, skipping underperforming leaders quicker. @@ -98,7 +98,7 @@ Each extension states when it acts, under what condition and what it changes: The timer and extensions of each consensus instance are logged when it starts, e.g. `timer=eager_dlinear timer_extensions=[double_total_on_rearm proposal_timeout]`. -### Gloas attester timer +### Gloas attester and payload attestation timer An Ethereum slot is split into consecutive intervals, each the timeframe of one kind of duty, starting at the duty's due time, its offset into the slot. From the gloas fork a 12s slot has four 3s intervals: the block proposal interval (0-3s), the attestation interval (3-6s), the aggregation interval (6-9s) and the payload attestation interval (9-12s). @@ -117,6 +117,14 @@ For gloas attester duties the previous interval is the block proposal interval, | Attestation due time (3s), if no block arrived | | Fetches the attestation data from all beacon nodes | | | End of each attempt (4s, 5s, 6s, then 8s, 11s, ...) | | | Round deadlines | +Payload attestation duties use the same timer. Their previous interval is the aggregation interval, which starts once the payload is due, and the payload attestation interval is split into 3 attempts, so rounds end at 10s, 11s, 12s, then 14s, 17s, etc. Payload timeliness committee members attest as soon as they have seen the payload and its blob data, but only report either as missing once the payload attestation due time has elapsed, since they may still arrive: + +| When | Scheduler | Fetcher | Round timer | +|------|-----------|---------|-------------| +| Payload due time (6s) | Triggers the duty | Fetches the payload attestation data, as the consensus input if it reports both the payload and its blob data as seen | Starts round 1 | +| Payload attestation due time (9s), if either wasn't seen | | Fetches the payload attestation data again, as the consensus input | | +| End of each attempt (10s, 11s, 12s, then 14s, 17s, ...) | | | Round deadlines | + ## Observability The four existing metrics are reflecting the consensus layer behavior: