Skip to content
2 changes: 1 addition & 1 deletion app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 13 additions & 6 deletions app/eth2wrap/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}

Expand Down
14 changes: 7 additions & 7 deletions app/eth2wrap/cache_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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})

Expand All @@ -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})

Expand Down Expand Up @@ -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{
Expand All @@ -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}},
Expand All @@ -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}},
Expand All @@ -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 {
Expand Down
63 changes: 57 additions & 6 deletions app/eth2wrap/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -732,15 +732,15 @@ 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)
})

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)
Expand All @@ -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)
}
}
31 changes: 31 additions & 0 deletions app/eth2wrap/synthproposer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 &eth2api.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)

Expand Down
41 changes: 41 additions & 0 deletions app/eth2wrap/synthproposer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, &eth2api.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)
}
6 changes: 3 additions & 3 deletions app/vmock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions core/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, &eth2api.ProposerDutiesOpts{Epoch: eth2p0.Epoch(slot.Epoch()), Indices: vals.Indexes()})
eth2Resp, err := proposerDuties(ctx, &eth2api.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
}
Expand Down
Loading
Loading