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 e0e6aee9c..022138ee2 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -613,19 +613,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 a6de31009..9b23ec250 100644 --- a/core/scheduler/scheduler_internal_test.go +++ b/core/scheduler/scheduler_internal_test.go @@ -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/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) {