From 7ae8c80fd12d26c30f94ce05f137eb351ca1d22a Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:35:29 +0300 Subject: [PATCH 1/7] app/eth2wrap: invalidate v1 proposer duties on epoch transition --- app/eth2wrap/cache.go | 10 ++++-- app/eth2wrap/cache_internal_test.go | 48 ++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/app/eth2wrap/cache.go b/app/eth2wrap/cache.go index 1070cc45b..c9396f8ef 100644 --- a/app/eth2wrap/cache.go +++ b/app/eth2wrap/cache.go @@ -317,9 +317,15 @@ type DutiesCache struct { syncDuties SyncDuties } -// Trim trims the cache of 3 epochs older than the current. -// This should be called on epoch boundary. +// Trim trims the cache of 3 epochs older than the current and invalidates the v1 proposer duties +// of the current and later epochs. This should be called on epoch boundary. func (c *DutiesCache) Trim(epoch eth2p0.Epoch) { + // The v1 proposer duties dependent root is the block root of the epoch's preceding slot, which + // isn't known before the epoch starts: duties cached earlier carry the head root at fetch time. + if epoch > 0 { + c.trimAfterProposerDuties(epoch - 1) + } + if epoch < dutiesCacheTrimThreshold { return } diff --git a/app/eth2wrap/cache_internal_test.go b/app/eth2wrap/cache_internal_test.go index 35de23e54..d788247a1 100644 --- a/app/eth2wrap/cache_internal_test.go +++ b/app/eth2wrap/cache_internal_test.go @@ -139,15 +139,53 @@ func TestDutiesCacheTrimCleansOldEpochs(t *testing.T) { func TestDutiesCacheTrimBelowThresholdIsNoop(t *testing.T) { cache := NewDutiesCache(nil, nil) - populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1, 2}) + populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1}) - // epoch=2 < dutiesCacheTrimThreshold(3), so Trim must be a no-op. + // epoch=2 < dutiesCacheTrimThreshold(3), so Trim must not trim the past epochs. cache.Trim(2) proposer, attester, sync := dutiesCacheLen(cache) - require.Equal(t, 3, proposer, "no entries should be trimmed when epoch < threshold") - require.Equal(t, 3, attester, "no entries should be trimmed when epoch < threshold") - require.Equal(t, 3, sync, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 2, proposer, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 2, attester, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 2, sync, "no entries should be trimmed when epoch < threshold") +} + +// TestDutiesCacheTrimInvalidatesProposerDuties verifies that Trim invalidates the v1 proposer duties +// of the current and later epochs, cached before their dependent root was known, keeping the rest. +func TestDutiesCacheTrimInvalidatesProposerDuties(t *testing.T) { + cache := NewDutiesCache(nil, nil) + + populateDutiesCache(t, cache, []eth2p0.Epoch{4, 5, 6}) + + for _, epoch := range []eth2p0.Epoch{4, 5, 6} { + stored, ok := cache.storeOrAmendProposerDutiesV2(epoch, ProposerDutiesForEpoch{ + duties: []eth2v1.ProposerDuty{{ValidatorIndex: 1}}, + metadata: map[string]any{"epoch": epoch}, + }) + require.True(t, ok) + require.Len(t, stored, 1) + } + + cache.Trim(5) + + _, ok := cache.fetchProposerDuties(4) + require.True(t, ok, "past epoch v1 proposer duties should be kept") + + for _, epoch := range []eth2p0.Epoch{5, 6} { + _, ok := cache.fetchProposerDuties(epoch) + require.False(t, ok, "epoch %d v1 proposer duties should be invalidated", epoch) + } + + for _, epoch := range []eth2p0.Epoch{4, 5, 6} { + _, ok := cache.fetchProposerDutiesV2(epoch) + require.True(t, ok, "epoch %d v2 proposer duties should be kept", epoch) + + _, ok = cache.fetchAttesterDuties(epoch) + require.True(t, ok, "epoch %d attester duties should be kept", epoch) + + _, ok = cache.fetchSyncDuties(epoch) + require.True(t, ok, "epoch %d sync duties should be kept", epoch) + } } // TestDutiesCacheTrimSequential verifies that successive Trim calls at advancing epochs From 4316551850cb1f9b73ef24e4c3fe313f290589f4 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:59:02 +0300 Subject: [PATCH 2/7] core/scheduler: resolve proposer duties via v2 --- app/eth2wrap/synthproposer.go | 10 ++++++++++ core/scheduler/scheduler.go | 7 ++++--- core/scheduler/scheduler_internal_test.go | 6 +++--- core/scheduler/scheduler_test.go | 6 +++--- testutil/beaconmock/beaconmock_fuzz.go | 2 ++ testutil/beaconmock/options.go | 9 +++++++-- testutil/integration/simnet_test.go | 3 ++- testutil/validatormock/builderpreferences.go | 10 ++++++++++ testutil/validatormock/builderpreferences_test.go | 4 +++- testutil/validatormock/proposerpreferences.go | 10 ++++++++++ testutil/validatormock/proposerpreferences_test.go | 4 +++- 11 files changed, 57 insertions(+), 14 deletions(-) diff --git a/app/eth2wrap/synthproposer.go b/app/eth2wrap/synthproposer.go index 8e206095d..fcec5d2c9 100644 --- a/app/eth2wrap/synthproposer.go +++ b/app/eth2wrap/synthproposer.go @@ -94,6 +94,16 @@ 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. +func (h *synthWrapper) ProposerDutiesV2(ctx context.Context, opts *eth2api.ProposerDutiesOpts) (*eth2api.Response[[]*eth2v1.ProposerDuty], error) { + return h.ProposerDuties(ctx, opts) +} + +// ProposerDutiesV2Cache wraps ProposerDutiesV2. We are not using cache for synthproposer. +func (h *synthWrapper) ProposerDutiesV2Cache(ctx context.Context, epoch eth2p0.Epoch, vidxs []eth2p0.ValidatorIndex) (ProposerDutyWithMeta, error) { + return h.ProposerDutiesCache(ctx, epoch, vidxs) +} + func (h *synthWrapper) SubmitProposalPreparations(ctx context.Context, preparations []*eth2v1.ProposalPreparation) error { h.setFeeRecipients(preparations) diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index e0e6aee9c..7f7ce5119 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -613,19 +613,20 @@ 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. It uses the v2 endpoint since, unlike v1's, +// its dependent root is already known when the next epoch's duties are resolved, at the epoch's last slot. func (s *Scheduler) resolveProDuties(ctx context.Context, slot core.Slot, vals validators) error { var proDuties []*eth2v1.ProposerDuty if featureset.Enabled(featureset.DisableDutiesCache) { - eth2Resp, err := s.eth2Cl.ProposerDuties(ctx, ð2api.ProposerDutiesOpts{Epoch: eth2p0.Epoch(slot.Epoch()), Indices: vals.Indexes()}) + eth2Resp, err := s.eth2Cl.ProposerDutiesV2(ctx, ð2api.ProposerDutiesOpts{Epoch: eth2p0.Epoch(slot.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 := s.eth2Cl.ProposerDutiesV2Cache(ctx, eth2p0.Epoch(slot.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 a6de31009..3bcc657e0 100644 --- a/core/scheduler/scheduler_internal_test.go +++ b/core/scheduler/scheduler_internal_test.go @@ -92,7 +92,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { // Randomize duty pubkeys oldAttesterFunc := eth2Cl.AttesterDutiesFunc oldSyncFunc := eth2Cl.SyncCommitteeDutiesFunc - oldProposerFunc := eth2Cl.ProposerDutiesFunc + oldProposerFunc := eth2Cl.ProposerDutiesV2Func eth2Cl.AttesterDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) { res, err := oldAttesterFunc(ctx, epoch, indices) @@ -146,7 +146,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { return eth2wrap.SyncDutyWithMeta{Duties: res, Metadata: nil}, nil } - eth2Cl.ProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + eth2Cl.ProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { res, err := oldProposerFunc(ctx, epoch, indices) if err != nil { return nil, err @@ -159,7 +159,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { return res, nil } - eth2Cl.CachedProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + eth2Cl.CachedProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { res, err := oldProposerFunc(ctx, epoch, indices) if err != nil { return eth2wrap.ProposerDutyWithMeta{}, err diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go index e74c87157..c7614d143 100644 --- a/core/scheduler/scheduler_test.go +++ b/core/scheduler/scheduler_test.go @@ -259,8 +259,8 @@ func TestSchedulerDuties(t *testing.T) { require.NoError(t, err) // Instrument ProposerDuties to returns some errors - origFunc := eth2Cl.ProposerDutiesFunc - eth2Cl.ProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + origFunc := eth2Cl.ProposerDutiesV2Func + eth2Cl.ProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { if test.PropErrs > 0 { test.PropErrs-- return nil, errors.New("test error") @@ -269,7 +269,7 @@ func TestSchedulerDuties(t *testing.T) { return origFunc(ctx, epoch, indices) } - eth2Cl.CachedProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + eth2Cl.CachedProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { if test.PropErrs > 0 { test.PropErrs-- return eth2wrap.ProposerDutyWithMeta{}, errors.New("test error") 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..1e3baf9b3 100644 --- a/testutil/validatormock/builderpreferences.go +++ b/testutil/validatormock/builderpreferences.go @@ -39,6 +39,16 @@ func BuilderPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Si epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + if err != nil { + return err + } + + // Preferences only exist from the gloas fork onwards. + if !forkSchedule.Active(eth2wrap.Gloas, epoch) { + return nil + } + var indexes []eth2p0.ValidatorIndex for index := range valMap { indexes = append(indexes, index) 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..2d597d3e9 100644 --- a/testutil/validatormock/proposerpreferences.go +++ b/testutil/validatormock/proposerpreferences.go @@ -38,6 +38,16 @@ func ProposerPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc S epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + if err != nil { + return err + } + + // Preferences only exist from the gloas fork onwards. + if !forkSchedule.Active(eth2wrap.Gloas, epoch) { + return nil + } + var indexes []eth2p0.ValidatorIndex for index := range valMap { indexes = append(indexes, index) 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 b92d4b06182b9b2deebfcf70550ef1060cde3e75 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:02:49 +0300 Subject: [PATCH 3/7] testutil/validatormock: share preferences epoch helper --- testutil/validatormock/builderpreferences.go | 11 ++------ testutil/validatormock/proposerpreferences.go | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/testutil/validatormock/builderpreferences.go b/testutil/validatormock/builderpreferences.go index 1e3baf9b3..d2da8badf 100644 --- a/testutil/validatormock/builderpreferences.go +++ b/testutil/validatormock/builderpreferences.go @@ -32,20 +32,13 @@ func BuilderPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Si return err } - _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) - if err != nil { - return err - } - - epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) - - forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + epoch, active, err := preferencesEpoch(ctx, eth2Cl, slot) if err != nil { return err } // Preferences only exist from the gloas fork onwards. - if !forkSchedule.Active(eth2wrap.Gloas, epoch) { + if !active { return nil } diff --git a/testutil/validatormock/proposerpreferences.go b/testutil/validatormock/proposerpreferences.go index 2d597d3e9..9e060feb0 100644 --- a/testutil/validatormock/proposerpreferences.go +++ b/testutil/validatormock/proposerpreferences.go @@ -31,20 +31,13 @@ func ProposerPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc S return err } - _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) - if err != nil { - return err - } - - epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) - - forkSchedule, err := eth2wrap.FetchForkConfig(ctx, eth2Cl) + epoch, active, err := preferencesEpoch(ctx, eth2Cl, slot) if err != nil { return err } // Preferences only exist from the gloas fork onwards. - if !forkSchedule.Active(eth2wrap.Gloas, epoch) { + if !active { return nil } @@ -120,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 +} From fcfa514e15a82bb6e0706eef9c751bab514ef642 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:10:51 +0300 Subject: [PATCH 4/7] app: invalidate v1 proposer duties entering the epoch before gloas --- app/app.go | 10 +++++++++- app/eth2wrap/cache.go | 23 +++++++++++++++-------- app/eth2wrap/cache_internal_test.go | 18 +++++++++--------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/app.go b/app/app.go index 0fa616713..2412de686 100644 --- a/app/app.go +++ b/app/app.go @@ -598,7 +598,15 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, valCache.Trim() if !featureset.Enabled(featureset.DisableDutiesCache) { - dutiesCache.Trim(eth2p0.Epoch(slot.Epoch())) + epoch := eth2p0.Epoch(slot.Epoch()) + dutiesCache.Trim(epoch) + + // VCs prefetching the epoch before the gloas fork get its v1 proposer duties cached with a provisional + // dependent root, which they sign into the first gloas epoch's proposer preferences. Drop them on + // entering that epoch, once its dependent root is known. + if fs := forkSchedule(); fs.Active(eth2wrap.Gloas, epoch+1) && !fs.Active(eth2wrap.Gloas, epoch) { + dutiesCache.InvalidateProposerDuties(ctx, epoch) + } } activeValidators, _, refresh, err := valCache.GetBySlot(ctx, slotToFetch) diff --git a/app/eth2wrap/cache.go b/app/eth2wrap/cache.go index c9396f8ef..36c910dd9 100644 --- a/app/eth2wrap/cache.go +++ b/app/eth2wrap/cache.go @@ -317,15 +317,9 @@ type DutiesCache struct { syncDuties SyncDuties } -// Trim trims the cache of 3 epochs older than the current and invalidates the v1 proposer duties -// of the current and later epochs. This should be called on epoch boundary. +// Trim trims the cache of 3 epochs older than the current. +// This should be called on epoch boundary. func (c *DutiesCache) Trim(epoch eth2p0.Epoch) { - // The v1 proposer duties dependent root is the block root of the epoch's preceding slot, which - // isn't known before the epoch starts: duties cached earlier carry the head root at fetch time. - if epoch > 0 { - c.trimAfterProposerDuties(epoch - 1) - } - if epoch < dutiesCacheTrimThreshold { return } @@ -336,6 +330,19 @@ func (c *DutiesCache) Trim(epoch eth2p0.Epoch) { c.trimBeforeSyncDuties(epoch - dutiesCacheTrimThreshold) } +// InvalidateProposerDuties invalidates the cached v1 proposer duties of the epoch and later ones. Their dependent +// root is the block root of the epoch's preceding slot, so duties cached before the epoch started hold the head +// root at fetch time instead. +func (c *DutiesCache) InvalidateProposerDuties(ctx context.Context, epoch eth2p0.Epoch) { + if epoch == 0 { + return + } + + if c.trimAfterProposerDuties(epoch - 1) { + log.Debug(ctx, "Invalidated cached v1 proposer duties", z.U64("from_epoch", uint64(epoch))) + } +} + // InvalidateCache handles chain reorg, invalidating cached duties. // The epoch parameter indicates the epoch the chain has reorged back to. // Meaning, we should invalidate all duties after that epoch. diff --git a/app/eth2wrap/cache_internal_test.go b/app/eth2wrap/cache_internal_test.go index d788247a1..8fba3927b 100644 --- a/app/eth2wrap/cache_internal_test.go +++ b/app/eth2wrap/cache_internal_test.go @@ -139,20 +139,20 @@ func TestDutiesCacheTrimCleansOldEpochs(t *testing.T) { func TestDutiesCacheTrimBelowThresholdIsNoop(t *testing.T) { cache := NewDutiesCache(nil, nil) - populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1}) + populateDutiesCache(t, cache, []eth2p0.Epoch{0, 1, 2}) - // epoch=2 < dutiesCacheTrimThreshold(3), so Trim must not trim the past epochs. + // epoch=2 < dutiesCacheTrimThreshold(3), so Trim must be a no-op. cache.Trim(2) proposer, attester, sync := dutiesCacheLen(cache) - require.Equal(t, 2, proposer, "no entries should be trimmed when epoch < threshold") - require.Equal(t, 2, attester, "no entries should be trimmed when epoch < threshold") - require.Equal(t, 2, sync, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 3, proposer, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 3, attester, "no entries should be trimmed when epoch < threshold") + require.Equal(t, 3, sync, "no entries should be trimmed when epoch < threshold") } -// TestDutiesCacheTrimInvalidatesProposerDuties verifies that Trim invalidates the v1 proposer duties -// of the current and later epochs, cached before their dependent root was known, keeping the rest. -func TestDutiesCacheTrimInvalidatesProposerDuties(t *testing.T) { +// TestDutiesCacheInvalidateProposerDuties verifies that InvalidateProposerDuties drops the v1 proposer +// duties of the epoch and later ones, keeping earlier epochs and the other duties. +func TestDutiesCacheInvalidateProposerDuties(t *testing.T) { cache := NewDutiesCache(nil, nil) populateDutiesCache(t, cache, []eth2p0.Epoch{4, 5, 6}) @@ -166,7 +166,7 @@ func TestDutiesCacheTrimInvalidatesProposerDuties(t *testing.T) { require.Len(t, stored, 1) } - cache.Trim(5) + cache.InvalidateProposerDuties(t.Context(), 5) _, ok := cache.fetchProposerDuties(4) require.True(t, ok, "past epoch v1 proposer duties should be kept") From a2a5e1285dae9cf3a3a152ff01a552a1d2dbf61c Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:33:42 +0300 Subject: [PATCH 5/7] core/scheduler: resolve proposer duties via v2 from gloas only --- core/scheduler/scheduler.go | 16 +++++-- core/scheduler/scheduler_internal_test.go | 57 +++++++++++++++++++++-- core/scheduler/scheduler_test.go | 6 +-- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 7f7ce5119..022138ee2 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -613,20 +613,28 @@ func (s *Scheduler) resolveAttDuties(ctx context.Context, slot core.Slot, vals v return nil } -// resolveProDuties resolves proposer duties for the given validators. It uses the v2 endpoint since, unlike v1's, -// its dependent root is already known when the next epoch's duties are resolved, at the epoch's last slot. +// 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.ProposerDutiesV2(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.ProposerDutiesV2Cache(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 3bcc657e0..9b23ec250 100644 --- a/core/scheduler/scheduler_internal_test.go +++ b/core/scheduler/scheduler_internal_test.go @@ -92,7 +92,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { // Randomize duty pubkeys oldAttesterFunc := eth2Cl.AttesterDutiesFunc oldSyncFunc := eth2Cl.SyncCommitteeDutiesFunc - oldProposerFunc := eth2Cl.ProposerDutiesV2Func + oldProposerFunc := eth2Cl.ProposerDutiesFunc eth2Cl.AttesterDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) { res, err := oldAttesterFunc(ctx, epoch, indices) @@ -146,7 +146,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { return eth2wrap.SyncDutyWithMeta{Duties: res, Metadata: nil}, nil } - eth2Cl.ProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + eth2Cl.ProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { res, err := oldProposerFunc(ctx, epoch, indices) if err != nil { return nil, err @@ -159,7 +159,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) { return res, nil } - eth2Cl.CachedProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + eth2Cl.CachedProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { res, err := oldProposerFunc(ctx, epoch, indices) if err != nil { return eth2wrap.ProposerDutyWithMeta{}, err @@ -184,8 +184,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, } @@ -395,3 +399,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/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go index c7614d143..e74c87157 100644 --- a/core/scheduler/scheduler_test.go +++ b/core/scheduler/scheduler_test.go @@ -259,8 +259,8 @@ func TestSchedulerDuties(t *testing.T) { require.NoError(t, err) // Instrument ProposerDuties to returns some errors - origFunc := eth2Cl.ProposerDutiesV2Func - eth2Cl.ProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + origFunc := eth2Cl.ProposerDutiesFunc + eth2Cl.ProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { if test.PropErrs > 0 { test.PropErrs-- return nil, errors.New("test error") @@ -269,7 +269,7 @@ func TestSchedulerDuties(t *testing.T) { return origFunc(ctx, epoch, indices) } - eth2Cl.CachedProposerDutiesV2Func = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { + eth2Cl.CachedProposerDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) { if test.PropErrs > 0 { test.PropErrs-- return eth2wrap.ProposerDutyWithMeta{}, errors.New("test error") From f0f056c1b697f49512ff0608647e6bc8ee0f2f22 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:35:13 +0300 Subject: [PATCH 6/7] app/eth2wrap: pass v2 proposer duties metadata through synth wrapper --- app/eth2wrap/synthproposer.go | 29 ++++++++++++++++++--- app/eth2wrap/synthproposer_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/app/eth2wrap/synthproposer.go b/app/eth2wrap/synthproposer.go index fcec5d2c9..3bf57a563 100644 --- a/app/eth2wrap/synthproposer.go +++ b/app/eth2wrap/synthproposer.go @@ -94,14 +94,35 @@ 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. +// 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) { - return h.ProposerDuties(ctx, opts) + 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 wraps ProposerDutiesV2. We are not using cache for synthproposer. +// 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) { - return h.ProposerDutiesCache(ctx, epoch, vidxs) + 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 { 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) +} From 903e8eed264e472f03b4a3537709cbb9b731525c Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:49:24 +0300 Subject: [PATCH 7/7] app/eth2wrap: don't cache v1 proposer duties from the epoch before gloas --- app/app.go | 12 +----- app/eth2wrap/cache.go | 32 ++++++--------- app/eth2wrap/cache_internal_test.go | 52 ++++-------------------- app/eth2wrap/cache_test.go | 63 ++++++++++++++++++++++++++--- app/vmock.go | 6 +-- 5 files changed, 82 insertions(+), 83 deletions(-) diff --git a/app/app.go b/app/app.go index 2412de686..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) @@ -598,15 +598,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, valCache.Trim() if !featureset.Enabled(featureset.DisableDutiesCache) { - epoch := eth2p0.Epoch(slot.Epoch()) - dutiesCache.Trim(epoch) - - // VCs prefetching the epoch before the gloas fork get its v1 proposer duties cached with a provisional - // dependent root, which they sign into the first gloas epoch's proposer preferences. Drop them on - // entering that epoch, once its dependent root is known. - if fs := forkSchedule(); fs.Active(eth2wrap.Gloas, epoch+1) && !fs.Active(eth2wrap.Gloas, epoch) { - dutiesCache.InvalidateProposerDuties(ctx, epoch) - } + dutiesCache.Trim(eth2p0.Epoch(slot.Epoch())) } activeValidators, _, refresh, err := valCache.GetBySlot(ctx, slotToFetch) diff --git a/app/eth2wrap/cache.go b/app/eth2wrap/cache.go index 36c910dd9..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 @@ -330,19 +332,6 @@ func (c *DutiesCache) Trim(epoch eth2p0.Epoch) { c.trimBeforeSyncDuties(epoch - dutiesCacheTrimThreshold) } -// InvalidateProposerDuties invalidates the cached v1 proposer duties of the epoch and later ones. Their dependent -// root is the block root of the epoch's preceding slot, so duties cached before the epoch started hold the head -// root at fetch time instead. -func (c *DutiesCache) InvalidateProposerDuties(ctx context.Context, epoch eth2p0.Epoch) { - if epoch == 0 { - return - } - - if c.trimAfterProposerDuties(epoch - 1) { - log.Debug(ctx, "Invalidated cached v1 proposer duties", z.U64("from_epoch", uint64(epoch))) - } -} - // InvalidateCache handles chain reorg, invalidating cached duties. // The epoch parameter indicates the epoch the chain has reorged back to. // Meaning, we should invalidate all duties after that epoch. @@ -400,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 @@ -484,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 8fba3927b..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}) @@ -150,48 +150,10 @@ func TestDutiesCacheTrimBelowThresholdIsNoop(t *testing.T) { require.Equal(t, 3, sync, "no entries should be trimmed when epoch < threshold") } -// TestDutiesCacheInvalidateProposerDuties verifies that InvalidateProposerDuties drops the v1 proposer -// duties of the epoch and later ones, keeping earlier epochs and the other duties. -func TestDutiesCacheInvalidateProposerDuties(t *testing.T) { - cache := NewDutiesCache(nil, nil) - - populateDutiesCache(t, cache, []eth2p0.Epoch{4, 5, 6}) - - for _, epoch := range []eth2p0.Epoch{4, 5, 6} { - stored, ok := cache.storeOrAmendProposerDutiesV2(epoch, ProposerDutiesForEpoch{ - duties: []eth2v1.ProposerDuty{{ValidatorIndex: 1}}, - metadata: map[string]any{"epoch": epoch}, - }) - require.True(t, ok) - require.Len(t, stored, 1) - } - - cache.InvalidateProposerDuties(t.Context(), 5) - - _, ok := cache.fetchProposerDuties(4) - require.True(t, ok, "past epoch v1 proposer duties should be kept") - - for _, epoch := range []eth2p0.Epoch{5, 6} { - _, ok := cache.fetchProposerDuties(epoch) - require.False(t, ok, "epoch %d v1 proposer duties should be invalidated", epoch) - } - - for _, epoch := range []eth2p0.Epoch{4, 5, 6} { - _, ok := cache.fetchProposerDutiesV2(epoch) - require.True(t, ok, "epoch %d v2 proposer duties should be kept", epoch) - - _, ok = cache.fetchAttesterDuties(epoch) - require.True(t, ok, "epoch %d attester duties should be kept", epoch) - - _, ok = cache.fetchSyncDuties(epoch) - require.True(t, ok, "epoch %d sync duties should be kept", epoch) - } -} - // 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}) @@ -220,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{ @@ -244,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}}, @@ -266,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}}, @@ -291,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/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