From 7eb0c3b36105b56489026e446464bc8572608c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= <4981644+OisinKyne@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:00:51 +0100 Subject: [PATCH 1/4] *: bump Go to 1.27.3 and distrust emSign/AAA roots in the image (#4796) Go 1.27.3 includes the crypto/x509 fix for CVE-2026-111943. Set toolchain go1.27.3 and bump the builder, promrated and CodeQL images. Distrust emSign ECC Root CA - G3 and AAA Certificate Services in the runtime image's system store until Debian's ca-certificates drops them everywhere. category: bug ticket: none --- .github/workflows/codeql-analysis.yml | 2 +- Dockerfile | 7 ++++++- go.mod | 2 ++ testutil/promrated/Dockerfile | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 46b7fbf38..6c20581ce 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,7 +21,7 @@ on: - cron: "18 19 * * 6" env: - GOLANG_VERSION: "1.27" + GOLANG_VERSION: "1.27.3" jobs: analyze: diff --git a/Dockerfile b/Dockerfile index 2b1c39091..b2c1a028a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Container for building Go binary. -FROM golang:1.27.2-trixie AS builder +FROM golang:1.27.3-trixie AS builder # Install dependencies RUN apt-get update && apt-get install -y --no-install-recommends build-essential git @@ -24,6 +24,11 @@ RUN echo "Built charon version=$(./charon version)" FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates fio wget \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Distrust the emSign ECC Root CA - G3 and AAA Certificate Services roots in the +# system store (used by wget and other non-Go tools in the image) until Debian's +# ca-certificates drops them. Remove once the base image ships the update. +RUN sed -i -E 's#^(mozilla/(emSign_ECC_Root_CA_-_G3|Comodo_AAA_Services_root)\.crt)$#!\1#' /etc/ca-certificates.conf \ + && update-ca-certificates --fresh ARG GITHUB_SHA=local ENV GITHUB_SHA=${GITHUB_SHA} diff --git a/go.mod b/go.mod index 03674087f..36768c274 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/obolnetwork/charon go 1.27 +toolchain go1.27.3 + require ( github.com/OffchainLabs/go-bitfield v0.0.0-20251031151322-f427d04d8506 github.com/attestantio/go-builder-client v0.8.0 diff --git a/testutil/promrated/Dockerfile b/testutil/promrated/Dockerfile index 267a7f3b3..361867069 100644 --- a/testutil/promrated/Dockerfile +++ b/testutil/promrated/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.27.2-alpine AS builder +FROM golang:1.27.3-alpine AS builder # Install dependencies RUN apk add --no-cache build-base git From 5277107e9552ae53027ff6c4e8c2a767483e8097 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:02:38 +0200 Subject: [PATCH 2/4] core: refetch payload attestation data at due time (#4797) * core: refetch payload attestation data at due time * core: ptc refetch cleanups and logging --- core/consensus/timer/roundtimer.go | 6 +- .../timer/roundtimer_internal_test.go | 31 ++- core/fetcher/fetcher.go | 79 ++++++-- core/fetcher/fetcher_internal_test.go | 179 ++++++++++++++++++ core/scheduler/scheduler.go | 29 ++- core/scheduler/scheduler_internal_test.go | 32 +++- core/slotoffset.go | 4 +- core/slotoffset_internal_test.go | 11 +- docs/consensus.md | 12 +- 9 files changed, 334 insertions(+), 49 deletions(-) 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: From 07cfd420b0e4d572f10912d3df31dcfd7a5388e8 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:07:24 +0200 Subject: [PATCH 3/4] *: fix v1 proposer duties dependent root before gloas (#4794) * app/eth2wrap: invalidate v1 proposer duties on epoch transition * core/scheduler: resolve proposer duties via v2 * testutil/validatormock: share preferences epoch helper * app: invalidate v1 proposer duties entering the epoch before gloas * core/scheduler: resolve proposer duties via v2 from gloas only * app/eth2wrap: pass v2 proposer duties metadata through synth wrapper * app/eth2wrap: don't cache v1 proposer duties from the epoch before gloas --- app/app.go | 2 +- app/eth2wrap/cache.go | 19 ++++-- app/eth2wrap/cache_internal_test.go | 14 ++--- app/eth2wrap/cache_test.go | 63 +++++++++++++++++-- app/eth2wrap/synthproposer.go | 31 +++++++++ app/eth2wrap/synthproposer_test.go | 41 ++++++++++++ app/vmock.go | 6 +- core/scheduler/scheduler.go | 15 ++++- core/scheduler/scheduler_internal_test.go | 51 +++++++++++++++ testutil/beaconmock/beaconmock_fuzz.go | 2 + testutil/beaconmock/options.go | 9 ++- testutil/integration/simnet_test.go | 3 +- testutil/validatormock/builderpreferences.go | 7 ++- .../validatormock/builderpreferences_test.go | 4 +- testutil/validatormock/proposerpreferences.go | 24 ++++++- .../validatormock/proposerpreferences_test.go | 4 +- 16 files changed, 260 insertions(+), 35 deletions(-) diff --git a/app/app.go b/app/app.go index 0fa616713..b61969f1a 100644 --- a/app/app.go +++ b/app/app.go @@ -556,7 +556,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, // Setup duties cache, refreshing it every epoch. var dutiesCache *eth2wrap.DutiesCache if !featureset.Enabled(featureset.DisableDutiesCache) { - dutiesCache = eth2wrap.NewDutiesCache(eth2Cl, []eth2p0.ValidatorIndex{}) + dutiesCache = eth2wrap.NewDutiesCache(eth2Cl, []eth2p0.ValidatorIndex{}, forkSchedule) eth2Cl.SetDutiesCache(dutiesCache.ProposerDutiesCache, dutiesCache.ProposerDutiesV2Cache, dutiesCache.AttesterDutiesCache, dutiesCache.SyncCommDutiesCache) sseListener.SubscribeChainReorgEvent(dutiesCache.InvalidateCache) sseListener.SetDutiesDependentRootFunc(dutiesCache.AttesterDutiesDependentRoot) diff --git a/app/eth2wrap/cache.go b/app/eth2wrap/cache.go index 1070cc45b..c7a113388 100644 --- a/app/eth2wrap/cache.go +++ b/app/eth2wrap/cache.go @@ -275,10 +275,11 @@ type CachedDutiesProvider interface { SyncCommDutiesCache(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) (SyncDutyWithMeta, error) } -// NewDutiesCache creates a new validator cache. -func NewDutiesCache(eth2Cl Client, valIdxs []eth2p0.ValidatorIndex) *DutiesCache { +// NewDutiesCache creates a new duties cache. The fork schedule function returns the schedule charon currently applies. +func NewDutiesCache(eth2Cl Client, valIdxs []eth2p0.ValidatorIndex, forkSchedule func() ForkForkSchedule) *DutiesCache { return &DutiesCache{ eth2Cl: eth2Cl, + forkSchedule: forkSchedule, activeValIdxs: ValIdxs{valIdxs: valIdxs}, proposerDuties: ProposerDuties{ @@ -307,6 +308,7 @@ func NewDutiesCache(eth2Cl Client, valIdxs []eth2p0.ValidatorIndex) *DutiesCache // DutiesCache caches active duties. type DutiesCache struct { eth2Cl Client + forkSchedule func() ForkForkSchedule activeValIdxs ValIdxs // proposerDuties and proposerDutiesV2 are cached separately: the endpoints return the @@ -387,8 +389,6 @@ func (c *DutiesCache) UpdateActiveValIndices(vidxs []eth2p0.ValidatorIndex) { } // ProposerDutiesCache returns the cached proposer duties, or fetches them if not available, populating the cache with the newly fetched ones. -// -//nolint:dupl // The logic is very similar between proposer, attester and sync duties, but the code is not easily reusable without adding complexity, hence the duplication. func (c *DutiesCache) ProposerDutiesCache(ctx context.Context, epoch eth2p0.Epoch, vidxs []eth2p0.ValidatorIndex) (ProposerDutyWithMeta, error) { cacheUsed := false @@ -471,13 +471,20 @@ func (c *DutiesCache) ProposerDutiesCache(ctx context.Context, epoch eth2p0.Epoc dutiesDeref = append(dutiesDeref, d) } + dutiesResult = append(dutiesResult, eth2Resp.Data...) + + // The v1 dependent root is the block root preceding the epoch, so duties fetched ahead of their epoch hold a + // provisional one, which VCs sign into the first gloas epoch's proposer preferences. Don't cache v1 duties from + // the epoch before the fork onwards, from which VCs resolve their duties via v2. + if c.forkSchedule().Active(Gloas, epoch+1) { + return ProposerDutyWithMeta{Duties: dutiesResult, Metadata: eth2Resp.Metadata}, nil + } + _, ok = c.storeOrAmendProposerDuties(epoch, ProposerDutiesForEpoch{duties: dutiesDeref, metadata: eth2Resp.Metadata, requestedIdxs: requestVidxs}) if !ok { log.Debug(ctx, "Failed to cache proposer duties - another routine already cached duties for this epoch, skipping", z.U64("epoch", uint64(epoch))) } - dutiesResult = append(dutiesResult, eth2Resp.Data...) - return ProposerDutyWithMeta{Duties: dutiesResult, Metadata: eth2Resp.Metadata}, nil } diff --git a/app/eth2wrap/cache_internal_test.go b/app/eth2wrap/cache_internal_test.go index 35de23e54..0d2a81931 100644 --- a/app/eth2wrap/cache_internal_test.go +++ b/app/eth2wrap/cache_internal_test.go @@ -105,7 +105,7 @@ func epochCached(c *DutiesCache, epoch eth2p0.Epoch) bool { // TestDutiesCacheTrimCleansOldEpochs verifies that Trim removes cached entries for epochs // older than (current - dutiesCacheTrimThreshold), preventing unbounded memory growth. func TestDutiesCacheTrimCleansOldEpochs(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) // Seed epochs 0 through 4. populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1, 2, 3, 4}) @@ -137,7 +137,7 @@ func TestDutiesCacheTrimCleansOldEpochs(t *testing.T) { // TestDutiesCacheTrimBelowThresholdIsNoop verifies that calling Trim with an epoch smaller // than dutiesCacheTrimThreshold is a no-op and does not clear any cached entries. func TestDutiesCacheTrimBelowThresholdIsNoop(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1, 2}) @@ -153,7 +153,7 @@ func TestDutiesCacheTrimBelowThresholdIsNoop(t *testing.T) { // TestDutiesCacheTrimSequential verifies that successive Trim calls at advancing epochs // progressively clean older entries, leaving only the most recent epochs. func TestDutiesCacheTrimSequential(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1, 2, 3, 4, 5}) @@ -182,7 +182,7 @@ func TestDutiesCacheRequestedIdxsNoDuplicates(t *testing.T) { const epoch = eth2p0.Epoch(5) t.Run("proposer", func(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) // Only validator 1 has a proposer duty; validators 2,3 have none. cache.storeOrAmendProposerDuties(epoch, ProposerDutiesForEpoch{ @@ -206,7 +206,7 @@ func TestDutiesCacheRequestedIdxsNoDuplicates(t *testing.T) { }) t.Run("attester", func(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) cache.storeOrAmendAttesterDuties(epoch, AttesterDutiesForEpoch{ duties: []eth2v1.AttesterDuty{{ValidatorIndex: 1}}, @@ -228,7 +228,7 @@ func TestDutiesCacheRequestedIdxsNoDuplicates(t *testing.T) { }) t.Run("sync", func(t *testing.T) { - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) cache.storeOrAmendSyncDuties(epoch, SyncDutiesForEpoch{ duties: []eth2v1.SyncCommitteeDuty{{ValidatorIndex: 1}}, @@ -253,7 +253,7 @@ func TestDutiesCacheRequestedIdxsNoDuplicates(t *testing.T) { func TestDutiesCacheTrimThousandEpochs(t *testing.T) { const total = 1000 - cache := NewDutiesCache(nil, nil) + cache := NewDutiesCache(nil, nil, nil) epochs := make([]eth2p0.Epoch, total) for i := range total { diff --git a/app/eth2wrap/cache_test.go b/app/eth2wrap/cache_test.go index 7c4bfd14b..eae090d68 100644 --- a/app/eth2wrap/cache_test.go +++ b/app/eth2wrap/cache_test.go @@ -269,7 +269,7 @@ func TestDutiesCache(t *testing.T) { } // Create a cache. - valCache := eth2wrap.NewDutiesCache(eth2Cl, slices.Collect(maps.Keys(valSet))) + valCache := newDutiesCache(t, eth2Cl, slices.Collect(maps.Keys(valSet))) ctx := t.Context() // First call should populate the cache @@ -440,7 +440,7 @@ func newCacheHarness(t *testing.T, a cacheAdapter) (*eth2wrap.DutiesCache, []eth bnCalls = append(bnCalls, slices.Clone(vidxs)) }, valSet) - return eth2wrap.NewDutiesCache(eth2Cl, allIdxs), allIdxs, &bnCalls + return newDutiesCache(t, eth2Cl, allIdxs), allIdxs, &bnCalls } func sortedIdxs(idxs []eth2p0.ValidatorIndex) []eth2p0.ValidatorIndex { @@ -631,7 +631,7 @@ func TestProposerDutiesV2Cache_IndependentOfV1(t *testing.T) { v2Calls = append(v2Calls, slices.Clone(vidxs)) }, valSet) - cache := eth2wrap.NewDutiesCache(eth2Cl, allIdxs) + cache := newDutiesCache(t, eth2Cl, allIdxs) ctx := t.Context() // Populate the v1 cache with all validators. @@ -721,7 +721,7 @@ func TestAttesterDutiesDependentRoot(t *testing.T) { idxs := []eth2p0.ValidatorIndex{1, 2} t.Run("cached root", func(t *testing.T) { - cache := eth2wrap.NewDutiesCache(rootedAttesterClient{Mock: bmock, root: eth2p0.Root{0x01}}, idxs) + cache := newDutiesCache(t, rootedAttesterClient{Mock: bmock, root: eth2p0.Root{0x01}}, idxs) _, err := cache.AttesterDutiesCache(t.Context(), 11, idxs) require.NoError(t, err) @@ -732,7 +732,7 @@ func TestAttesterDutiesDependentRoot(t *testing.T) { }) t.Run("uncached epoch", func(t *testing.T) { - cache := eth2wrap.NewDutiesCache(rootedAttesterClient{Mock: bmock, root: eth2p0.Root{0x01}}, idxs) + cache := newDutiesCache(t, rootedAttesterClient{Mock: bmock, root: eth2p0.Root{0x01}}, idxs) _, ok := cache.AttesterDutiesDependentRoot(11) require.False(t, ok) @@ -740,7 +740,7 @@ func TestAttesterDutiesDependentRoot(t *testing.T) { t.Run("zero root is unknown", func(t *testing.T) { // beaconmock returns a zero dependent root. - cache := eth2wrap.NewDutiesCache(bmock, idxs) + cache := newDutiesCache(t, bmock, idxs) _, err := cache.AttesterDutiesCache(t.Context(), 11, idxs) require.NoError(t, err) @@ -749,3 +749,54 @@ func TestAttesterDutiesDependentRoot(t *testing.T) { require.False(t, ok) }) } + +// newDutiesCache returns a duties cache applying the client's fork schedule. +func newDutiesCache(t *testing.T, eth2Cl eth2wrap.Client, vidxs []eth2p0.ValidatorIndex) *eth2wrap.DutiesCache { + t.Helper() + + forkSchedule, err := eth2wrap.FetchForkConfig(t.Context(), eth2Cl) + require.NoError(t, err) + + return eth2wrap.NewDutiesCache(eth2Cl, vidxs, func() eth2wrap.ForkForkSchedule { return forkSchedule }) +} + +// TestProposerDutiesCache_NotCachedFromEpochBeforeGloas verifies that v1 proposer duties are cached up to two epochs +// before the gloas fork and not from the epoch before it onwards, since ones fetched ahead of their epoch hold a +// provisional dependent root which VCs sign into the first gloas epoch's proposer preferences. +func TestProposerDutiesCache_NotCachedFromEpochBeforeGloas(t *testing.T) { + ctx := context.Background() + valSet := beaconmock.ValidatorSetA + + eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet), + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "5")) + require.NoError(t, err) + + var calls int + + eth2Cl.ProposerDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + calls++ + + return []*eth2v1.ProposerDuty{}, nil + } + + cache := newDutiesCache(t, eth2Cl, slices.Collect(maps.Keys(valSet))) + + tests := []struct { + epoch eth2p0.Epoch + wantCalls int + }{ + {epoch: 3, wantCalls: 1}, // Cached. + {epoch: 4, wantCalls: 3}, // Epoch before gloas, not cached. + {epoch: 5, wantCalls: 5}, // Gloas, not cached. + } + + for _, test := range tests { + for range 2 { + _, err := cache.ProposerDutiesCache(ctx, test.epoch, nil) + require.NoError(t, err) + } + + require.Equal(t, test.wantCalls, calls, "epoch %d", test.epoch) + } +} diff --git a/app/eth2wrap/synthproposer.go b/app/eth2wrap/synthproposer.go index 8e206095d..3bf57a563 100644 --- a/app/eth2wrap/synthproposer.go +++ b/app/eth2wrap/synthproposer.go @@ -94,6 +94,37 @@ func (h *synthWrapper) ProposerDutiesCache(ctx context.Context, epoch eth2p0.Epo return ProposerDutyWithMeta{Duties: eth2Resp.Data, Metadata: eth2Resp.Metadata}, nil } +// ProposerDutiesV2 returns the same duties as ProposerDuties, the two matching under the fulu proposer lookahead, +// with the upstream v2 metadata, whose dependent root validator clients sign into their proposer preferences. +func (h *synthWrapper) ProposerDutiesV2(ctx context.Context, opts *eth2api.ProposerDutiesOpts) (*eth2api.Response[[]*eth2v1.ProposerDuty], error) { + duties, err := h.synthProposerCache.Duties(ctx, h.Client, opts.Epoch) + if err != nil { + return nil, err + } + + eth2Resp, err := h.Client.ProposerDutiesV2(ctx, opts) + if err != nil { + return nil, err + } + + return ð2api.Response[[]*eth2v1.ProposerDuty]{Data: duties, Metadata: eth2Resp.Metadata}, nil +} + +// ProposerDutiesV2Cache returns the same duties as ProposerDutiesV2, with the metadata from the wrapped client's cache. +func (h *synthWrapper) ProposerDutiesV2Cache(ctx context.Context, epoch eth2p0.Epoch, vidxs []eth2p0.ValidatorIndex) (ProposerDutyWithMeta, error) { + duties, err := h.synthProposerCache.Duties(ctx, h.Client, epoch) + if err != nil { + return ProposerDutyWithMeta{}, err + } + + cached, err := h.Client.ProposerDutiesV2Cache(ctx, epoch, vidxs) + if err != nil { + return ProposerDutyWithMeta{}, err + } + + return ProposerDutyWithMeta{Duties: duties, Metadata: cached.Metadata}, nil +} + func (h *synthWrapper) SubmitProposalPreparations(ctx context.Context, preparations []*eth2v1.ProposalPreparation) error { h.setFeeRecipients(preparations) diff --git a/app/eth2wrap/synthproposer_test.go b/app/eth2wrap/synthproposer_test.go index f7f4733de..813aee249 100644 --- a/app/eth2wrap/synthproposer_test.go +++ b/app/eth2wrap/synthproposer_test.go @@ -477,3 +477,44 @@ func TestSynthProposerBlockNotFound(t *testing.T) { require.Equal(t, timesCalled, int(duty.Slot)-1) } } + +// TestSynthProposerDutiesV2Metadata verifies that synthetic v2 proposer duties carry the upstream v2 metadata, +// whose dependent root validator clients sign into their gloas proposer preferences. +func TestSynthProposerDutiesV2Metadata(t *testing.T) { + ctx := context.Background() + + var ( + set = beaconmock.ValidatorSetA + slotsPerEpoch = 3 + epoch eth2p0.Epoch = 1 + realSlot = eth2p0.Slot(slotsPerEpoch) * eth2p0.Slot(epoch) + metadata = map[string]any{"execution_optimistic": false, "dependent_root": testutil.RandomRoot()} + ) + + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch)) + require.NoError(t, err) + + realDuties := []*eth2v1.ProposerDuty{{PubKey: set[1].Validator.PublicKey, Slot: realSlot, ValidatorIndex: set[1].Index}} + + bmock.ProposerDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + return realDuties, nil + } + // The mock wraps the v2 response with a zero dependent root, so the cached variant below is the one carrying it. + bmock.ProposerDutiesV2Func = bmock.ProposerDutiesFunc + bmock.CachedProposerDutiesV2Func = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + return eth2wrap.ProposerDutyWithMeta{Duties: realDuties, Metadata: metadata}, nil + } + + eth2Cl := eth2wrap.WithSyntheticDuties(bmock) + + cached, err := eth2Cl.ProposerDutiesV2Cache(ctx, epoch, nil) + require.NoError(t, err) + require.Equal(t, metadata, cached.Metadata) + require.Len(t, cached.Duties, slotsPerEpoch, "real duty plus synthetic duties for the other slots") + + resp, err := eth2Cl.ProposerDutiesV2(ctx, ð2api.ProposerDutiesOpts{Epoch: epoch}) + require.NoError(t, err) + require.NotNil(t, resp.Metadata) + require.Contains(t, resp.Metadata, "dependent_root") + require.Len(t, resp.Data, slotsPerEpoch) +} diff --git a/app/vmock.go b/app/vmock.go index 4d3eb9bda..5e597ee00 100644 --- a/app/vmock.go +++ b/app/vmock.go @@ -47,14 +47,14 @@ func wireValidatorMock(ctx context.Context, conf Config, eth2Cl eth2wrap.Client, return err } - vmock := validatormock.New(ctx, newVMockEth2Provider(conf, pubshares), signer, pubshares, genesisTime, slotDuration, slotsPerEpoch, slotOffsetFunc, conf.BuilderAPI, conf.BuilderURLs) + vmock := validatormock.New(ctx, newVMockEth2Provider(conf, forkSchedule, pubshares), signer, pubshares, genesisTime, slotDuration, slotsPerEpoch, slotOffsetFunc, conf.BuilderAPI, conf.BuilderURLs) sched.SubscribeSlots(vmock.SlotTicked) return nil } // newVMockEth2Provider returns a function that returns a cached validator mock eth2 client. -func newVMockEth2Provider(conf Config, pubshares []eth2p0.BLSPubKey) func() (eth2wrap.Client, error) { +func newVMockEth2Provider(conf Config, forkSchedule func() eth2wrap.ForkForkSchedule, pubshares []eth2p0.BLSPubKey) func() (eth2wrap.Client, error) { var ( cached eth2wrap.Client mu sync.Mutex @@ -96,7 +96,7 @@ func newVMockEth2Provider(conf Config, pubshares []eth2p0.BLSPubKey) func() (eth valCache := eth2wrap.NewValidatorCache(cached, pubshares) cached.SetValidatorCache(valCache.GetByHead) - dutiesCache := eth2wrap.NewDutiesCache(cached, []eth2p0.ValidatorIndex{}) + dutiesCache := eth2wrap.NewDutiesCache(cached, []eth2p0.ValidatorIndex{}, forkSchedule) cached.SetDutiesCache(dutiesCache.ProposerDutiesCache, dutiesCache.ProposerDutiesV2Cache, dutiesCache.AttesterDutiesCache, dutiesCache.SyncCommDutiesCache) break diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 5ad7b0b0b..60656802b 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -628,19 +628,28 @@ func (s *Scheduler) resolveAttDuties(ctx context.Context, slot core.Slot, vals v return nil } -// resolveProDuties resolves proposer duties for the given validators. +// resolveProDuties resolves proposer duties for the given validators. From the gloas fork it uses the v2 endpoint, +// whose dependent root is already known when the next epoch's duties are resolved at the epoch's last slot, and +// which only gloas-ready beacon nodes serve. func (s *Scheduler) resolveProDuties(ctx context.Context, slot core.Slot, vals validators) error { var proDuties []*eth2v1.ProposerDuty + epoch := eth2p0.Epoch(slot.Epoch()) + + proposerDuties, proposerDutiesCache := s.eth2Cl.ProposerDuties, s.eth2Cl.ProposerDutiesCache + if s.forkSchedule().Active(eth2wrap.Gloas, epoch) { + proposerDuties, proposerDutiesCache = s.eth2Cl.ProposerDutiesV2, s.eth2Cl.ProposerDutiesV2Cache + } + if featureset.Enabled(featureset.DisableDutiesCache) { - eth2Resp, err := s.eth2Cl.ProposerDuties(ctx, ð2api.ProposerDutiesOpts{Epoch: eth2p0.Epoch(slot.Epoch()), Indices: vals.Indexes()}) + eth2Resp, err := proposerDuties(ctx, ð2api.ProposerDutiesOpts{Epoch: epoch, Indices: vals.Indexes()}) if err != nil { return err } proDuties = eth2Resp.Data } else { - cachedResp, err := s.eth2Cl.ProposerDutiesCache(ctx, eth2p0.Epoch(slot.Epoch()), vals.Indexes()) + cachedResp, err := proposerDutiesCache(ctx, epoch, vals.Indexes()) if err != nil { return err } diff --git a/core/scheduler/scheduler_internal_test.go b/core/scheduler/scheduler_internal_test.go index 853523582..14f0cd238 100644 --- a/core/scheduler/scheduler_internal_test.go +++ b/core/scheduler/scheduler_internal_test.go @@ -182,8 +182,12 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { }) } + forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + require.NoError(t, err) + sched := &Scheduler{ eth2Cl: eth2Cl, + forkSchedule: func() eth2wrap.ForkForkSchedule { return forkSchedule }, builderEnabled: false, } @@ -421,3 +425,50 @@ func TestHandleHeadEventGloas(t *testing.T) { }) } } + +// TestResolveProDutiesVersion verifies that proposer duties are resolved via v1 before the gloas fork and +// via v2 from it, since v2 is only served by gloas-ready beacon nodes. +func TestResolveProDutiesVersion(t *testing.T) { + var t0 time.Time + + eth2Cl, err := beaconmock.New( + t.Context(), + beaconmock.WithValidatorSet(beaconmock.ValidatorSetA), + beaconmock.WithGenesisTime(t0), + beaconmock.WithDeterministicAttesterDuties(0), + beaconmock.WithDeterministicProposerDuties(0), + beaconmock.WithDeterministicSyncCommDuties(2, 2), + beaconmock.WithSlotsPerEpoch(1), + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "2"), + ) + require.NoError(t, err) + + var v1Epochs, v2Epochs []eth2p0.Epoch + + v1Func, v2Func := eth2Cl.CachedProposerDutiesFunc, eth2Cl.CachedProposerDutiesV2Func + eth2Cl.CachedProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, vidxs []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + v1Epochs = append(v1Epochs, epoch) + + return v1Func(ctx, epoch, vidxs) + } + eth2Cl.CachedProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, vidxs []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + v2Epochs = append(v2Epochs, epoch) + + return v2Func(ctx, epoch, vidxs) + } + + forkSchedule, err := eth2wrap.FetchForkConfig(t.Context(), eth2Cl) + require.NoError(t, err) + + sched, err := New(t.Context(), nil, eth2Cl, func() eth2wrap.ForkForkSchedule { return forkSchedule }, false) + require.NoError(t, err) + + for epoch := range uint64(3) { + slot := core.Slot{Slot: epoch, Time: t0.Add(time.Duration(epoch) * time.Second), SlotDuration: time.Second, SlotsPerEpoch: 1} + require.NoError(t, sched.resolveDuties(t.Context(), slot)) + } + + require.Equal(t, []eth2p0.Epoch{0, 1}, v1Epochs) + require.Equal(t, []eth2p0.Epoch{2}, v2Epochs) +} diff --git a/testutil/beaconmock/beaconmock_fuzz.go b/testutil/beaconmock/beaconmock_fuzz.go index b38a12806..ec35885ae 100644 --- a/testutil/beaconmock/beaconmock_fuzz.go +++ b/testutil/beaconmock/beaconmock_fuzz.go @@ -121,6 +121,8 @@ func WithBeaconMockFuzzer() Option { return eth2wrap.ProposerDutyWithMeta{Duties: duties, Metadata: nil}, nil } + mock.ProposerDutiesV2Func = mock.ProposerDutiesFunc + mock.CachedProposerDutiesV2Func = mock.CachedProposerDutiesFunc mock.AttestationDataFunc = func(context.Context, eth2p0.Slot, eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) { var attData *eth2p0.AttestationData diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 98fabc80b..3e9f29968 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -414,12 +414,15 @@ func WithDeterministicAttesterDuties(factor int) Option { } } -// WithDeterministicProposerDuties configures the mock to provide deterministic duties based on provided arguments and config. +// WithDeterministicProposerDuties configures the mock to provide deterministic v1 and v2 duties based on provided +// arguments and config, the two returning the same duties as under the fulu proposer lookahead. // Note it depends on ValidatorsFunc being populated, e.g. via WithValidatorSet. func WithDeterministicProposerDuties(factor int) Option { return func(mock *Mock) { mock.ProposerDutiesFunc = deterministicProposerDuties(mock, factor) mock.CachedProposerDutiesFunc = wrapDutiesWithMeta(mock.ProposerDutiesFunc) + mock.ProposerDutiesV2Func = mock.ProposerDutiesFunc + mock.CachedProposerDutiesV2Func = mock.CachedProposerDutiesFunc } } @@ -490,7 +493,7 @@ func wrapDutiesWithMeta(fn func(context.Context, eth2p0.Epoch, []eth2p0.Validato } } -// WithNoProposerDuties configures the mock to override ProposerDutiesFunc to return nothing. +// WithNoProposerDuties configures the mock to override the v1 and v2 proposer duties funcs to return nothing. func WithNoProposerDuties() Option { return func(mock *Mock) { mock.ProposerDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { @@ -499,6 +502,8 @@ func WithNoProposerDuties() Option { mock.CachedProposerDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { return eth2wrap.ProposerDutyWithMeta{}, nil } + mock.ProposerDutiesV2Func = mock.ProposerDutiesFunc + mock.CachedProposerDutiesV2Func = mock.CachedProposerDutiesFunc } } diff --git a/testutil/integration/simnet_test.go b/testutil/integration/simnet_test.go index 92de367d5..3662fa777 100644 --- a/testutil/integration/simnet_test.go +++ b/testutil/integration/simnet_test.go @@ -99,7 +99,8 @@ func TestSimnetDuties(t *testing.T) { { name: "gloas epbs proposer with mock VCs", scheduledType: core.DutyProposer, - duties: []core.DutyType{core.DutyProposer, core.DutyRandao, core.DutyExecutionPayloadEnvelope}, + // The mock VCs also sign proposer preferences for their gloas proposals. + duties: []core.DutyType{core.DutyProposer, core.DutyRandao, core.DutyExecutionPayloadEnvelope, core.DutyProposerPreferences}, vcType: vcVmock, gloasProposer: true, }, diff --git a/testutil/validatormock/builderpreferences.go b/testutil/validatormock/builderpreferences.go index e9a6eccbc..d2da8badf 100644 --- a/testutil/validatormock/builderpreferences.go +++ b/testutil/validatormock/builderpreferences.go @@ -32,12 +32,15 @@ func BuilderPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Si return err } - _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) + epoch, active, err := preferencesEpoch(ctx, eth2Cl, slot) if err != nil { return err } - epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + // Preferences only exist from the gloas fork onwards. + if !active { + return nil + } var indexes []eth2p0.ValidatorIndex for index := range valMap { diff --git a/testutil/validatormock/builderpreferences_test.go b/testutil/validatormock/builderpreferences_test.go index 67820a445..e6e8c2429 100644 --- a/testutil/validatormock/builderpreferences_test.go +++ b/testutil/validatormock/builderpreferences_test.go @@ -43,7 +43,9 @@ func TestBuilderPreferences(t *testing.T) { newMock := func(t *testing.T) beaconmock.Mock { t.Helper() - bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet)) + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet), + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "0")) require.NoError(t, err) bmock.ProposerDutiesV2Func = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { diff --git a/testutil/validatormock/proposerpreferences.go b/testutil/validatormock/proposerpreferences.go index 733772ba6..9e060feb0 100644 --- a/testutil/validatormock/proposerpreferences.go +++ b/testutil/validatormock/proposerpreferences.go @@ -31,12 +31,15 @@ func ProposerPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc S return err } - _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) + epoch, active, err := preferencesEpoch(ctx, eth2Cl, slot) if err != nil { return err } - epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + // Preferences only exist from the gloas fork onwards. + if !active { + return nil + } var indexes []eth2p0.ValidatorIndex for index := range valMap { @@ -110,3 +113,20 @@ func dependentRoot(metadata map[string]any) eth2p0.Root { return root } + +// preferencesEpoch returns the epoch of the proposal slot and whether the gloas fork is active at it. +func preferencesEpoch(ctx context.Context, eth2Cl eth2wrap.Client, slot eth2p0.Slot) (eth2p0.Epoch, bool, error) { + _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) + if err != nil { + return 0, false, err + } + + epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + + forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + if err != nil { + return 0, false, err + } + + return epoch, forkSchedule.Active(eth2wrap.Gloas, epoch), nil +} diff --git a/testutil/validatormock/proposerpreferences_test.go b/testutil/validatormock/proposerpreferences_test.go index 686a3aa5f..d4f8dfcd7 100644 --- a/testutil/validatormock/proposerpreferences_test.go +++ b/testutil/validatormock/proposerpreferences_test.go @@ -43,7 +43,9 @@ func TestProposerPreferences(t *testing.T) { newMock := func(t *testing.T) beaconmock.Mock { t.Helper() - bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet)) + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet), + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "0")) require.NoError(t, err) bmock.ProposerDutiesV2Func = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { From 6f181ee87eac2fed80a3ea990617b74e0cfda9b9 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:08:18 +0200 Subject: [PATCH 4/4] cmd: default gas limits to 200M (#4795) * cmd: default target gas limit to 200M * app: add proposer-gas-limit flag defaulting to 200M --- app/app.go | 6 +- app/builderregistration.go | 38 +------- app/builderregistration_internal_test.go | 8 +- app/proposerconfig.go | 95 ++++++++++---------- app/proposerconfig_internal_test.go | 105 +++++++++-------------- cmd/cmd_internal_test.go | 2 + cmd/createcluster.go | 2 +- cmd/createdkg.go | 2 +- cmd/run.go | 5 ++ core/validatorapi/validatorapi.go | 10 +-- docs/configuration.md | 1 + 11 files changed, 108 insertions(+), 166 deletions(-) diff --git a/app/app.go b/app/app.go index b61969f1a..c058da5db 100644 --- a/app/app.go +++ b/app/app.go @@ -104,7 +104,7 @@ type Config struct { ConsensusProtocol string Nickname string BeaconNodeHeaders []string - TargetGasLimit uint + ProposerGasLimit uint64 FallbackBeaconNodeAddrs []string ExecutionEngineAddr string Graffiti []string @@ -511,7 +511,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartBuilderRegWatcher, lifecycle.HookFuncCtx(builderRegSvc.Run)) - wireProposerConfigFile(ctx, conf, lock, nodeIdx, builderRegSvc.FeeRecipient, builderRegSvc.GasLimit) + wireProposerConfigFile(ctx, conf, lock, nodeIdx, builderRegSvc.FeeRecipient) peers, err := lock.Peers() if err != nil { @@ -655,7 +655,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, dutyDB := dutydb.NewMemDB(retainerFunc("dutydb")) - vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, builderRegSvc.FeeRecipient, conf.BuilderAPI, lock.TargetGasLimit, + vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, builderRegSvc.FeeRecipient, conf.BuilderAPI, conf.ProposerGasLimit, validatorapi.WithBuilderHosts(hosts)) if err != nil { return err diff --git a/app/builderregistration.go b/app/builderregistration.go index 98292700f..d896c61c2 100644 --- a/app/builderregistration.go +++ b/app/builderregistration.go @@ -45,9 +45,6 @@ type BuilderRegistrationService interface { Registrations() []*eth2api.VersionedSignedValidatorRegistration // FeeRecipient returns the current fee recipient address for the given pubkey. FeeRecipient(pubkey core.PubKey) string - // GasLimit returns the current builder registration gas limit for the given pubkey, - // zero if unknown. - GasLimit(pubkey core.PubKey) uint64 // Run watches the overrides file for changes, periodically fetches from the // Obol API, and reloads when either source is updated. It blocks until ctx is cancelled. Run(ctx context.Context) @@ -158,7 +155,6 @@ type builderRegistrationService struct { baseFeeRecipients map[core.PubKey]string registrations []*eth2api.VersionedSignedValidatorRegistration feeRecipients map[core.PubKey]string - gasLimits map[core.PubKey]uint64 // Fields for background API fetching. obolClient *obolapi.Client @@ -216,14 +212,6 @@ func (s *builderRegistrationService) FeeRecipient(pubkey core.PubKey) string { return s.feeRecipients[pubkey] } -// GasLimit returns the current builder registration gas limit for the given pubkey. -func (s *builderRegistrationService) GasLimit(pubkey core.PubKey) uint64 { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.gasLimits[pubkey] -} - // Run watches the overrides file for changes, periodically fetches from the Obol API, // and reloads when either source is updated. It blocks until ctx is cancelled. func (s *builderRegistrationService) Run(ctx context.Context) { @@ -376,13 +364,12 @@ func (s *builderRegistrationService) fetchFromAPI(ctx context.Context) (bool, er // and updates the effective registrations and fee recipients. func (s *builderRegistrationService) recompute(ctx context.Context) { feeRecipients := maps.Clone(s.baseFeeRecipients) - gasLimits := gasLimitsByPubkey(s.baseRegistrations) overrides := mergeOverrides(s.fileOverrides, s.apiOverrides) var regs []*eth2api.VersionedSignedValidatorRegistration if len(overrides) > 0 { - regs = applyBuilderRegistrationOverrides(ctx, s.baseRegistrations, overrides, feeRecipients, gasLimits) + regs = applyBuilderRegistrationOverrides(ctx, s.baseRegistrations, overrides, feeRecipients) } else { regs = s.baseRegistrations } @@ -392,27 +379,6 @@ func (s *builderRegistrationService) recompute(ctx context.Context) { s.registrations = regs s.feeRecipients = feeRecipients - s.gasLimits = gasLimits -} - -// gasLimitsByPubkey maps validator pubkeys to their builder registration gas limit. -func gasLimitsByPubkey(registrations []*eth2api.VersionedSignedValidatorRegistration) map[core.PubKey]uint64 { - res := make(map[core.PubKey]uint64, len(registrations)) - - for _, reg := range registrations { - if reg == nil || reg.V1 == nil || reg.V1.Message == nil || reg.V1.Message.GasLimit == 0 { - continue - } - - pubkey, err := core.PubKeyFromBytes(reg.V1.Message.Pubkey[:]) - if err != nil { - continue - } - - res[pubkey] = reg.V1.Message.GasLimit - } - - return res } // mergeOverrides combines two override slices, keeping the entry with the highest @@ -609,7 +575,6 @@ func applyBuilderRegistrationOverrides( builderRegs []*eth2api.VersionedSignedValidatorRegistration, overrides []*eth2api.VersionedSignedValidatorRegistration, feeRecipientByPubkey map[core.PubKey]string, - gasLimitByPubkey map[core.PubKey]uint64, ) []*eth2api.VersionedSignedValidatorRegistration { // Build lookup from overrides keyed by lowercase pubkey hex. overrideByPubkey := make(map[string]*eth2api.VersionedSignedValidatorRegistration, len(overrides)) @@ -649,7 +614,6 @@ func applyBuilderRegistrationOverrides( } feeRecipientByPubkey[corePubkey] = "0x" + hex.EncodeToString(override.V1.Message.FeeRecipient[:]) - gasLimitByPubkey[corePubkey] = override.V1.Message.GasLimit log.Info(ctx, "Applied builder registration override for 0x"+key, z.Str("fee_recipient", feeRecipientByPubkey[corePubkey])) } diff --git a/app/builderregistration_internal_test.go b/app/builderregistration_internal_test.go index ad0f10162..ebaa9dbb7 100644 --- a/app/builderregistration_internal_test.go +++ b/app/builderregistration_internal_test.go @@ -193,7 +193,7 @@ func Test_applyBuilderRegistrationOverrides(t *testing.T) { t.Run("no overrides", func(t *testing.T) { feeMap := maps.Clone(feeRecipientByPubkey) - result := applyBuilderRegistrationOverrides(ctx, baseRegs, nil, feeMap, map[core.PubKey]uint64{}) + result := applyBuilderRegistrationOverrides(ctx, baseRegs, nil, feeMap) require.Equal(t, baseRegs, result) }) @@ -213,7 +213,7 @@ func Test_applyBuilderRegistrationOverrides(t *testing.T) { }, } - result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap, map[core.PubKey]uint64{}) + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) require.Equal(t, override, result[0]) @@ -237,7 +237,7 @@ func Test_applyBuilderRegistrationOverrides(t *testing.T) { }, } - result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap, map[core.PubKey]uint64{}) + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) require.Equal(t, baseRegs[0], result[0]) }) @@ -257,7 +257,7 @@ func Test_applyBuilderRegistrationOverrides(t *testing.T) { }, } - result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap, map[core.PubKey]uint64{}) + result := applyBuilderRegistrationOverrides(ctx, baseRegs, []*eth2api.VersionedSignedValidatorRegistration{override}, feeMap) for i := range baseRegs { require.Equal(t, baseRegs[i], result[i]) diff --git a/app/proposerconfig.go b/app/proposerconfig.go index ddf479b9d..8ed9365c6 100644 --- a/app/proposerconfig.go +++ b/app/proposerconfig.go @@ -16,7 +16,6 @@ import ( "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/core" - "github.com/obolnetwork/charon/eth2util/registration" ) // proposerConfigDir and proposerConfigFilename locate the generated validator client @@ -94,11 +93,12 @@ type builderEntryJSON struct { } // writeProposerConfigFile generates the validator client proposer configuration file -// if it doesn't already exist, mirroring what charon registers with builders: the -// effective builder registrations including operator overrides, falling back to the -// cluster lock. An existing file is never modified. +// if it doesn't already exist. Fee recipients mirror what charon registers with builders: +// the effective builder registrations including operator overrides, falling back to the +// cluster lock. The gas limit is the configured proposer gas limit. An existing file is +// never modified. func writeProposerConfigFile(conf Config, lock *cluster.Lock, nodeIdx cluster.NodeIdx, - feeRecipientFunc func(core.PubKey) string, gasLimitFunc func(core.PubKey) uint64, + feeRecipientFunc func(core.PubKey) string, ) (bool, error) { dir := filepath.Join(filepath.Dir(conf.LockFile), proposerConfigDir) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -124,13 +124,11 @@ func writeProposerConfigFile(conf Config, lock *cluster.Lock, nodeIdx cluster.No type valSettings struct { pubshareHex string feeRecipient string - gasLimit uint64 } var ( all []valSettings feeCounts = make(map[string]int) - gasCounts = make(map[uint64]int) ) for vi, val := range lock.Validators { @@ -149,37 +147,23 @@ func writeProposerConfigFile(conf Config, lock *cluster.Lock, nodeIdx cluster.No feeRecipient = feeRecipients[vi] } - gasLimit := gasLimitFunc(corePubkey) - if gasLimit == 0 { - gasLimit = registration.DefaultGasLimit - } - vs := valSettings{ pubshareHex: fmt.Sprintf("%#x", pubshare), feeRecipient: feeRecipient, - gasLimit: gasLimit, } all = append(all, vs) feeCounts[vs.feeRecipient]++ - gasCounts[vs.gasLimit]++ } - // The default config holds the per-field majority values, ties broken by the - // first validator holding them, so the common uniform cluster emits no entries. - var ( - defaultFee string - defaultGas uint64 - ) + // The default config holds the majority fee recipient, ties broken by the first + // validator holding it, so the common uniform cluster emits no entries. + var defaultFee string for _, vs := range all { if feeCounts[vs.feeRecipient] > feeCounts[defaultFee] { defaultFee = vs.feeRecipient } - - if gasCounts[vs.gasLimit] > gasCounts[defaultGas] { - defaultGas = vs.gasLimit - } } config := proposerConfigJSON{ @@ -187,28 +171,18 @@ func writeProposerConfigFile(conf Config, lock *cluster.Lock, nodeIdx cluster.No ProposerConfig: make(map[string]proposerSettingsJSON), DefaultConfig: proposerSettingsJSON{ FeeRecipient: defaultFee, - GasLimit: strconv.FormatUint(defaultGas, 10), + GasLimit: strconv.FormatUint(conf.ProposerGasLimit, 10), Builder: builderSettings(conf), }, } // Entries only carry the fields diverging from the default config. for _, vs := range all { - var entry proposerSettingsJSON - - if vs.feeRecipient != defaultFee { - entry.FeeRecipient = vs.feeRecipient - } - - if vs.gasLimit != defaultGas { - entry.GasLimit = strconv.FormatUint(vs.gasLimit, 10) - } - - if entry == (proposerSettingsJSON{}) { + if vs.feeRecipient == defaultFee { continue // Covered by the default config. } - config.ProposerConfig[vs.pubshareHex] = entry + config.ProposerConfig[vs.pubshareHex] = proposerSettingsJSON{FeeRecipient: vs.feeRecipient} } b, err := json.Marshal(config) @@ -244,20 +218,29 @@ func builderSettings(conf Config) *builderSettingsJSON { } } -// proposerConfigBuilderMatches returns whether the builder settings of the existing proposer -// config file match the configured builder flags. -func proposerConfigBuilderMatches(conf Config, path string) (bool, error) { +// proposerConfigMatches returns whether the builder settings and the gas limits of the +// existing proposer config file match the configured builder and proposer gas limit flags. +func proposerConfigMatches(conf Config, path string) (builderMatches bool, gasMatches bool, err error) { b, err := os.ReadFile(path) if err != nil { - return false, errors.Wrap(err, "read proposer config file", z.Str("path", path)) + return false, false, errors.Wrap(err, "read proposer config file", z.Str("path", path)) } var config proposerConfigJSON if err := json.Unmarshal(b, &config); err != nil { - return false, errors.Wrap(err, "decode proposer config file", z.Str("path", path)) + return false, false, errors.Wrap(err, "decode proposer config file", z.Str("path", path)) + } + + gasLimit := strconv.FormatUint(conf.ProposerGasLimit, 10) + + gasMatches = config.DefaultConfig.GasLimit == gasLimit + for _, entry := range config.ProposerConfig { + if entry.GasLimit != "" && entry.GasLimit != gasLimit { + gasMatches = false + } } - return builderSettingsEqual(config.DefaultConfig.Builder, builderSettings(conf)), nil + return builderSettingsEqual(config.DefaultConfig.Builder, builderSettings(conf)), gasMatches, nil } // builderSettingsEqual returns whether both builder settings are absent, or equal. @@ -272,18 +255,18 @@ func builderSettingsEqual(a, b *builderSettingsJSON) bool { slices.Equal(a.Builders, b.Builders) } -// wireProposerConfigFile generates the proposer config file, or warns if the builder settings of -// an existing one differ from the builder flags, logging failures instead of returning them so -// generation never prevents duties. +// wireProposerConfigFile generates the proposer config file, or warns if the builder settings or +// gas limits of an existing one differ from the flags, logging failures instead of returning them +// so generation never prevents duties. func wireProposerConfigFile(ctx context.Context, conf Config, lock *cluster.Lock, nodeIdx cluster.NodeIdx, - feeRecipientFunc func(core.PubKey) string, gasLimitFunc func(core.PubKey) uint64, + feeRecipientFunc func(core.PubKey) string, ) { if conf.TestConfig.Lock != nil { // In-memory test lock, there is no cluster directory to write the file into. return } - created, err := writeProposerConfigFile(conf, lock, nodeIdx, feeRecipientFunc, gasLimitFunc) + created, err := writeProposerConfigFile(conf, lock, nodeIdx, feeRecipientFunc) if err != nil { log.Error(ctx, "Failed generating proposer config file, VC proposer settings must be configured manually", err) return @@ -296,12 +279,20 @@ func wireProposerConfigFile(ctx context.Context, conf Config, lock *cluster.Lock return } - // The builder flags only seed a new file, the cluster changes builders together. - matches, err := proposerConfigBuilderMatches(conf, path) + // The flags only seed a new file, the cluster changes builders and gas limits together. + builderMatches, gasMatches, err := proposerConfigMatches(conf, path) if err != nil { - log.Warn(ctx, "Failed checking builder settings of the proposer config file", err) - } else if !matches { + log.Warn(ctx, "Failed checking the proposer config file against the flags", err) + return + } + + if !builderMatches { log.Warn(ctx, "Builder flags differ from the proposer config file and only apply to a new file, "+ "change its builders with `charon proposerconfig edit` in sync with the other peers", nil, z.Str("path", path)) } + + if !gasMatches { + log.Warn(ctx, "Proposer gas limit flag differs from the proposer config file and only applies to a new file, "+ + "change its gas limits in sync with the other peers", nil, z.Str("path", path), z.U64("proposer_gas_limit", conf.ProposerGasLimit)) + } } diff --git a/app/proposerconfig_internal_test.go b/app/proposerconfig_internal_test.go index 2ae8c7aad..daea26791 100644 --- a/app/proposerconfig_internal_test.go +++ b/app/proposerconfig_internal_test.go @@ -8,17 +8,12 @@ import ( "math/rand" "os" "path/filepath" - "strconv" "testing" - eth2api "github.com/attestantio/go-eth2-client/api" - eth2v1 "github.com/attestantio/go-eth2-client/api/v1" - eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/core" - "github.com/obolnetwork/charon/eth2util/registration" ) func TestWriteProposerConfigFile(t *testing.T) { @@ -32,16 +27,17 @@ func TestWriteProposerConfigFile(t *testing.T) { dir := t.TempDir() conf := Config{ - LockFile: filepath.Join(dir, "cluster-lock.json"), - BuilderAPI: true, + LockFile: filepath.Join(dir, "cluster-lock.json"), + BuilderAPI: true, + ProposerGasLimit: 200_000_000, } nodeIdx := cluster.NodeIdx{PeerIdx: peerIdx, ShareIdx: peerIdx + 1} path := filepath.Join(dir, proposerConfigDir, proposerConfigFilename) - // Override the first validator's fee recipient and gas limit (mimicking builder - // registration overrides), let the second fall back to its lock address, and - // give the remaining two identical settings so they form the majority. + // Override the first validator's fee recipient (mimicking builder registration + // overrides), let the second fall back to its lock address, and give the + // remaining two identical fee recipients so they form the majority. overridePubkey, err := core.PubKeyFromBytes(lock.Validators[0].PubKey) require.NoError(t, err) @@ -64,17 +60,7 @@ func TestWriteProposerConfigFile(t *testing.T) { } } - const overrideGasLimit = 42_000_000 - - gasLimitFunc := func(pubkey core.PubKey) uint64 { - if pubkey == overridePubkey { - return overrideGasLimit - } - - return 0 // Exercise the default fallback for the rest. - } - - created, err := writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc, gasLimitFunc) + created, err := writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc) require.NoError(t, err) require.True(t, created) @@ -86,7 +72,6 @@ func TestWriteProposerConfigFile(t *testing.T) { require.NoError(t, json.Unmarshal(b, &config)) require.EqualValues(t, 1, config.Version) - defaultGasLimit := strconv.FormatUint(registration.DefaultGasLimit, 10) feeRecipients := lock.FeeRecipientAddresses() pubshare := func(vi int) string { @@ -96,15 +81,15 @@ func TestWriteProposerConfigFile(t *testing.T) { return fmt.Sprintf("%#x", share) } - // The default config holds the majority settings (validators 2 and 3). + // The default config holds the majority fee recipient (validators 2 and 3) and the + // configured proposer gas limit. require.Equal(t, majorityAddr, config.DefaultConfig.FeeRecipient) - require.Equal(t, defaultGasLimit, config.DefaultConfig.GasLimit) + require.Equal(t, "200000000", config.DefaultConfig.GasLimit) require.Nil(t, config.DefaultConfig.Builder) // No builder URLs configured. - // The two diverging validators get entries carrying only the diverging fields: - // validator 0 diverges in both, validator 1 only in its fee recipient. + // The two diverging validators get entries carrying only their fee recipient. require.Len(t, config.ProposerConfig, 2) - require.Equal(t, proposerSettingsJSON{FeeRecipient: overrideAddr, GasLimit: strconv.FormatUint(overrideGasLimit, 10)}, config.ProposerConfig[pubshare(0)]) + require.Equal(t, proposerSettingsJSON{FeeRecipient: overrideAddr}, config.ProposerConfig[pubshare(0)]) require.Equal(t, proposerSettingsJSON{FeeRecipient: feeRecipients[1]}, config.ProposerConfig[pubshare(1)]) // No pre-gloas legacy fields in the charon schema. @@ -114,7 +99,7 @@ func TestWriteProposerConfigFile(t *testing.T) { // An existing file is never modified. require.NoError(t, os.WriteFile(path, []byte("operator managed"), 0o644)) - created, err = writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc, gasLimitFunc) + created, err = writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc) require.NoError(t, err) require.False(t, created) @@ -126,7 +111,7 @@ func TestWriteProposerConfigFile(t *testing.T) { require.NoError(t, os.Remove(path)) require.NoError(t, os.Mkdir(path, 0o755)) - _, err = writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc, gasLimitFunc) + _, err = writeProposerConfigFile(conf, &lock, nodeIdx, feeRecipientFunc) require.ErrorContains(t, err, "is a directory") } @@ -135,7 +120,6 @@ func TestWriteProposerConfigFileBuilder(t *testing.T) { nodeIdx := cluster.NodeIdx{PeerIdx: 0, ShareIdx: 1} noOverrides := func(core.PubKey) string { return "" } - noGasOverrides := func(core.PubKey) uint64 { return 0 } t.Run("builder urls", func(t *testing.T) { dir := t.TempDir() @@ -146,9 +130,10 @@ func TestWriteProposerConfigFileBuilder(t *testing.T) { BuilderMinBid: 1_000_000_000, BuilderBoostFactor: 90, BuilderMaxExecutionPayment: 2_000_000_000, + ProposerGasLimit: 200_000_000, } - created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides, noGasOverrides) + created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides) require.NoError(t, err) require.True(t, created) @@ -162,7 +147,7 @@ func TestWriteProposerConfigFileBuilder(t *testing.T) { // A uniform cluster emits no per-validator entries, the default covers all. require.Empty(t, config.ProposerConfig) - require.Equal(t, strconv.FormatUint(registration.DefaultGasLimit, 10), config.DefaultConfig.GasLimit) + require.Equal(t, "200000000", config.DefaultConfig.GasLimit) // The builder configuration is only emitted on the default config, with the // shared values at the builder level and entries carrying no overrides. @@ -183,7 +168,7 @@ func TestWriteProposerConfigFileBuilder(t *testing.T) { BuilderMinBid: 1, // Inert without builder URLs (the flags reject this combination). } - created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides, noGasOverrides) + created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides) require.NoError(t, err) require.True(t, created) @@ -199,12 +184,11 @@ func TestWriteProposerConfigFileBuilder(t *testing.T) { }) } -func TestProposerConfigBuilderMatches(t *testing.T) { +func TestProposerConfigMatches(t *testing.T) { lock, _, _ := cluster.NewForT(t, 1, 4, 4, 0, rand.New(rand.NewSource(0))) nodeIdx := cluster.NodeIdx{PeerIdx: 0, ShareIdx: 1} noOverrides := func(core.PubKey) string { return "" } - noGasOverrides := func(core.PubKey) uint64 { return 0 } dir := t.TempDir() path := filepath.Join(dir, proposerConfigDir, proposerConfigFilename) @@ -215,36 +199,52 @@ func TestProposerConfigBuilderMatches(t *testing.T) { BuilderAPI: true, BuilderURLs: builderURLs, BuilderBoostFactor: 90, + ProposerGasLimit: 200_000_000, } } conf := newConf("https://builder1.example.com") - created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides, noGasOverrides) + created, err := writeProposerConfigFile(conf, &lock, nodeIdx, noOverrides) require.NoError(t, err) require.True(t, created) b, err := os.ReadFile(path) require.NoError(t, err) - matches, err := proposerConfigBuilderMatches(conf, path) + builderMatches, gasMatches, err := proposerConfigMatches(conf, path) require.NoError(t, err) - require.True(t, matches) + require.True(t, builderMatches) + require.True(t, gasMatches) // Changed builder URLs, builder flags or no builder URLs differ, and leave the file as it is. changedBoost := conf changedBoost.BuilderBoostFactor = 100 for _, changed := range []Config{newConf("https://builder2.example.com"), changedBoost, newConf()} { - created, err = writeProposerConfigFile(changed, &lock, nodeIdx, noOverrides, noGasOverrides) + created, err = writeProposerConfigFile(changed, &lock, nodeIdx, noOverrides) require.NoError(t, err) require.False(t, created) - matches, err = proposerConfigBuilderMatches(changed, path) + builderMatches, gasMatches, err = proposerConfigMatches(changed, path) require.NoError(t, err) - require.False(t, matches) + require.False(t, builderMatches) + require.True(t, gasMatches) } + // A changed proposer gas limit differs, and leaves the file as it is. + changedGas := conf + changedGas.ProposerGasLimit = 60_000_000 + + created, err = writeProposerConfigFile(changedGas, &lock, nodeIdx, noOverrides) + require.NoError(t, err) + require.False(t, created) + + builderMatches, gasMatches, err = proposerConfigMatches(changedGas, path) + require.NoError(t, err) + require.True(t, builderMatches) + require.False(t, gasMatches) + unchanged, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, b, unchanged) @@ -252,27 +252,6 @@ func TestProposerConfigBuilderMatches(t *testing.T) { // A file that isn't a proposer config is an error. require.NoError(t, os.WriteFile(path, []byte("operator managed"), 0o644)) - _, err = proposerConfigBuilderMatches(conf, path) + _, _, err = proposerConfigMatches(conf, path) require.ErrorContains(t, err, "decode proposer config file") } - -func TestGasLimitsByPubkey(t *testing.T) { - lock, _, _ := cluster.NewForT(t, 1, 3, 3, 0, rand.New(rand.NewSource(0))) - - pubkey := eth2p0.BLSPubKey(lock.Validators[0].PubKey) - - corePubkey, err := core.PubKeyFromBytes(pubkey[:]) - require.NoError(t, err) - - regs := []*eth2api.VersionedSignedValidatorRegistration{ - {V1: ð2v1.SignedValidatorRegistration{Message: ð2v1.ValidatorRegistration{ - Pubkey: pubkey, - GasLimit: 50_000_000, - }}}, - nil, // Nil and zero gas limit entries are skipped. - {V1: ð2v1.SignedValidatorRegistration{Message: ð2v1.ValidatorRegistration{}}}, - } - - require.Equal(t, map[core.PubKey]uint64{corePubkey: 50_000_000}, gasLimitsByPubkey(regs)) - require.Empty(t, gasLimitsByPubkey(nil)) -} diff --git a/cmd/cmd_internal_test.go b/cmd/cmd_internal_test.go index 2a2f60a67..551825eb0 100644 --- a/cmd/cmd_internal_test.go +++ b/cmd/cmd_internal_test.go @@ -87,6 +87,7 @@ func TestCmdFlags(t *testing.T) { PublishAddress: "https://api.obol.tech/v1", PublishTimeout: 5 * time.Minute, BuilderBoostFactor: 100, + ProposerGasLimit: 200_000_000, }, }, { @@ -142,6 +143,7 @@ func TestCmdFlags(t *testing.T) { PublishAddress: "https://api.obol.tech/v1", PublishTimeout: 5 * time.Minute, BuilderBoostFactor: 100, + ProposerGasLimit: 200_000_000, TestConfig: app.TestConfig{ P2PFuzz: true, }, diff --git a/cmd/createcluster.go b/cmd/createcluster.go index 856fefc95..1c9aa1432 100644 --- a/cmd/createcluster.go +++ b/cmd/createcluster.go @@ -150,7 +150,7 @@ func bindClusterFlags(flags *pflag.FlagSet, config *clusterConfig) { flags.BoolVar(&config.Publish, "publish", false, "Publish lock file to obol-api.") flags.IntSliceVar(&config.DepositAmounts, "deposit-amounts", nil, "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") flags.StringVar(&config.ConsensusProtocol, "consensus-protocol", "", "Preferred consensus protocol name for the cluster. Selected automatically when not specified.") - flags.UintVar(&config.TargetGasLimit, "target-gas-limit", 60000000, "Preferred target gas limit for transactions.") + flags.UintVar(&config.TargetGasLimit, "target-gas-limit", 200000000, "Preferred target gas limit for transactions.") flags.BoolVar(&config.Compounding, "compounding", false, "Enable compounding rewards for validators by using 0x02 withdrawal credentials.") flags.StringVar(&config.ExecutionEngineAddr, "execution-client-rpc-endpoint", "", "The address of the execution engine JSON-RPC API.") flags.BoolVar(&config.Zipped, "zipped", false, "Create a tar archive compressed with gzip of the cluster directory after creation.") diff --git a/cmd/createdkg.go b/cmd/createdkg.go index 5d5d650a6..ff8cf4430 100644 --- a/cmd/createdkg.go +++ b/cmd/createdkg.go @@ -106,7 +106,7 @@ func bindCreateDKGFlags(cmd *cobra.Command, config *createDKGConfig) { cmd.Flags().IntSliceVar(&config.DepositAmounts, "deposit-amounts", nil, "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.") cmd.Flags().StringSliceVar(&config.OperatorENRs, "operator-enrs", nil, "Comma-separated list of each operator's Charon ENR address.") cmd.Flags().StringVar(&config.ConsensusProtocol, "consensus-protocol", "", "Preferred consensus protocol name for the cluster. Selected automatically when not specified.") - cmd.Flags().UintVar(&config.TargetGasLimit, "target-gas-limit", 60000000, "Preferred target gas limit for transactions.") + cmd.Flags().UintVar(&config.TargetGasLimit, "target-gas-limit", 200000000, "Preferred target gas limit for transactions.") cmd.Flags().BoolVar(&config.Compounding, "compounding", false, "Enable compounding rewards for validators by using 0x02 withdrawal credentials.") cmd.Flags().StringVar(&config.ExecutionEngineAddr, "execution-client-rpc-endpoint", "", "The address of the execution engine JSON-RPC API.") cmd.Flags().BoolVar(&config.Publish, "publish", false, "Creates an invitation to the DKG ceremony on the DV Launchpad. Terms and conditions apply.") diff --git a/cmd/run.go b/cmd/run.go index f7f9e22d2..b632ad029 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -116,6 +116,7 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) { cmd.Flags().Uint64Var(&config.BuilderMinBid, "builder-min-bid", 0, "Minimum builder bid value in gwei, bids below it lose to the locally built payload. Applies from the gloas fork onwards. Requires builder-urls. Must be identical on all nodes in the cluster.") cmd.Flags().Uint64Var(&config.BuilderBoostFactor, "builder-boost-factor", 100, "Percentage multiplier applied to builder bid values when comparing them to the locally built payload. Applies from the gloas fork onwards. Requires builder-urls. Must be identical on all nodes in the cluster.") cmd.Flags().Uint64Var(&config.BuilderMaxExecutionPayment, "builder-max-execution-payment", 0, "Maximum execution layer payment in gwei counted when valuing a builder bid, 0 counts no execution payment (trustless bids only). Applies from the gloas fork onwards. Requires builder-urls. Must be identical on all nodes in the cluster.") + cmd.Flags().Uint64Var(&config.ProposerGasLimit, "proposer-gas-limit", 200_000_000, "Preferred target gas limit for proposed execution payloads, seeding the generated validator client proposer config. Applies from the gloas fork onwards. Must be identical on all nodes in the cluster.") wrapPreRunE(cmd, func(cc *cobra.Command, _ []string) error { if len(config.BeaconNodeAddrs) == 0 && !config.SimnetBMock { @@ -169,6 +170,10 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) { } } + if config.ProposerGasLimit == 0 { + return errors.New("proposer-gas-limit must be greater than 0") + } + if config.BuilderBoostFactor == 0 { return errors.New("builder-boost-factor must be greater than 0") } diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index 592cdad59..8b5c9a412 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -93,7 +93,7 @@ func NewComponentInsecure(_ *testing.T, eth2Cl eth2wrap.Client, shareIdx int) (* // NewComponent returns a new instance of the validator API core workflow component. func NewComponent(eth2Cl eth2wrap.Client, allPubSharesByKey map[core.PubKey]map[int]tbls.PublicKey, - shareIdx int, feeRecipientFunc func(core.PubKey) string, builderEnabled bool, targetGasLimit uint, opts ...Option, + shareIdx int, feeRecipientFunc func(core.PubKey) string, builderEnabled bool, targetGasLimit uint64, opts ...Option, ) (*Component, error) { var ( sharesByKey = make(map[eth2p0.BLSPubKey]eth2p0.BLSPubKey) @@ -199,7 +199,7 @@ type Component struct { insecureTest bool feeRecipientFunc func(core.PubKey) string builderEnabled bool - targetGasLimit uint + targetGasLimit uint64 builderHosts []string swallowRegFilter z.Field @@ -1708,11 +1708,11 @@ func (c Component) warnProposerPreferencesMismatch(ctx context.Context, pubkey c incProposerPrefMismatch("fee_recipient") } - // TargetGasLimit is only enforced for cluster-lock versions that support it (non-zero). - if c.targetGasLimit != 0 && msg.TargetGasLimit != uint64(c.targetGasLimit) { + // TargetGasLimit is only checked when configured (non-zero). + if c.targetGasLimit != 0 && msg.TargetGasLimit != c.targetGasLimit { log.Warn(ctx, "Proposer preferences with unexpected target gas limit", nil, z.Any("pubkey", pubkey), - z.U64("expected", uint64(c.targetGasLimit)), + z.U64("expected", c.targetGasLimit), z.U64("actual", msg.TargetGasLimit)) incProposerPrefMismatch("gas_limit") } diff --git a/docs/configuration.md b/docs/configuration.md index 043453b3b..359ee5bfb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,6 +204,7 @@ Flags: --private-key-file string The path to the charon enr private key file. (default ".charon/charon-enr-private-key") --private-key-file-lock Enables private key locking to prevent multiple instances using the same key. --proc-directory string Directory to look into in order to detect other stack components running on the host. Enabling this exports the command lines of detected validator clients to the monitoring endpoint and debug logs, with the values of secret-shaped flags redacted. + --proposer-gas-limit uint Preferred target gas limit for proposed execution payloads, seeding the generated validator client proposer config. Applies from the gloas fork onwards. Must be identical on all nodes in the cluster. (default 200000000) --publish-address string The URL of the remote API for background fee recipient fetching. (default "https://api.obol.tech/v1") --publish-timeout duration Timeout for accessing the remote API. (default 5m0s) --simnet-beacon-mock Enables an internal mock beacon node for running a simnet.