diff --git a/core/validatorapi/eth2types.go b/core/validatorapi/eth2types.go index 2b54ace55..dd2736749 100644 --- a/core/validatorapi/eth2types.go +++ b/core/validatorapi/eth2types.go @@ -73,6 +73,13 @@ type proposerDutiesResponse struct { ExecutionOptimistic bool `json:"execution_optimistic"` } +// ptcDutiesResponse defines the response to the ptcDuties endpoint. +type ptcDutiesResponse struct { + DependentRoot root `json:"dependent_root"` + Data []*eth2v1.PTCDuty `json:"data"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + type proposeBlockV3Response struct { ExecutionPayloadBlinded bool `json:"execution_payload_blinded"` ExecutionPayloadValue string `json:"execution_payload_value"` diff --git a/core/validatorapi/mocks/handler.go b/core/validatorapi/mocks/handler.go index 5da33c729..ba8e32cc5 100644 --- a/core/validatorapi/mocks/handler.go +++ b/core/validatorapi/mocks/handler.go @@ -214,6 +214,36 @@ func (_m *Handler) NodeVersion(ctx context.Context, opts *api.NodeVersionOpts) ( return r0, r1 } +// PTCDuties provides a mock function with given fields: ctx, opts +func (_m *Handler) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for PTCDuties") + } + + var r0 *api.Response[[]*v1.PTCDuty] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) *api.Response[[]*v1.PTCDuty]); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.Response[[]*v1.PTCDuty]) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *api.PTCDutiesOpts) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PayloadAttestationData provides a mock function with given fields: ctx, opts func (_m *Handler) PayloadAttestationData(ctx context.Context, opts *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) { ret := _m.Called(ctx, opts) diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go index 04200ced5..58ec16ba3 100644 --- a/core/validatorapi/router.go +++ b/core/validatorapi/router.go @@ -81,6 +81,7 @@ type Handler interface { eth2client.PayloadAttestationDataProvider eth2client.PayloadAttestationMessagesSubmitter eth2client.ProposerDutiesProvider + eth2client.PTCDutiesProvider eth2client.SyncCommitteeContributionProvider eth2client.SyncCommitteeContributionsSubmitter eth2client.SyncCommitteeDutiesProvider @@ -123,6 +124,13 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { Methods: []string{http.MethodGet}, Encodings: []contentType{contentTypeJSON}, }, + { + Name: "ptc_duties", + Path: "/eth/v1/validator/duties/ptc/{epoch}", + Handler: ptcDuties(h), + Methods: []string{http.MethodPost}, + Encodings: []contentType{contentTypeJSON}, + }, { Name: "proposer_duties_v2", Path: "/eth/v2/validator/duties/proposer/{epoch}", @@ -797,6 +805,52 @@ func attesterDuties(p eth2client.AttesterDutiesProvider) handlerFunc { } } +// ptcDuties returns a handler function for the payload timeliness committee duty endpoint. +func ptcDuties(p eth2client.PTCDutiesProvider) handlerFunc { + return func(ctx context.Context, params map[string]string, _ http.Header, _ url.Values, typ contentType, body []byte) (any, http.Header, error) { + epoch, err := uintParam(params, "epoch") + if err != nil { + return nil, nil, err + } + + var req valIndexesJSON + if err := unmarshal(typ, body, &req); err != nil { + return nil, nil, err + } + + opts := ð2api.PTCDutiesOpts{ + Epoch: eth2p0.Epoch(epoch), + Indices: req, + } + + eth2Resp, err := p.PTCDuties(ctx, opts) + if err != nil { + return nil, nil, err + } + + data := eth2Resp.Data + if len(data) == 0 { // Return empty json array instead of null + data = []*eth2v1.PTCDuty{} + } + + executionOptimistic, err := getExecutionOptimisticFromMetadata(eth2Resp.Metadata) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to decode PTCDuties response metadata") + } + + dependentRoot, err := getDependentRootFromMetadata(eth2Resp.Metadata) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to decode PTCDuties response metadata") + } + + return ptcDutiesResponse{ + ExecutionOptimistic: executionOptimistic, + DependentRoot: dependentRoot, + Data: data, + }, nil, nil + } +} + // syncCommitteeDuties returns a handler function for the sync committee duty endpoint. func syncCommitteeDuties(p eth2client.SyncCommitteeDutiesProvider) handlerFunc { return func(ctx context.Context, params map[string]string, _ http.Header, _ url.Values, typ contentType, body []byte) (any, http.Header, error) { diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go index 4c88edc51..9c798e6e1 100644 --- a/core/validatorapi/router_internal_test.go +++ b/core/validatorapi/router_internal_test.go @@ -936,6 +936,56 @@ func TestRouter(t *testing.T) { testRouter(t, handler, callback) }) + t.Run("ptcduty", func(t *testing.T) { + handler := testHandler{ + PTCDutiesFunc: func(_ context.Context, opts *eth2api.PTCDutiesOpts) (*eth2api.Response[[]*eth2v1.PTCDuty], error) { + var res []*eth2v1.PTCDuty + for _, index := range opts.Indices { + res = append(res, ð2v1.PTCDuty{ + ValidatorIndex: index, // Echo index + Slot: eth2p0.Slot(slotsPerEpoch * opts.Epoch), // Echo first slot of epoch + PubKey: testutil.RandomEth2PubKey(t), + }) + } + + return wrapResponseWithMetadata(res, metadata), nil + }, + } + + callback := func(ctx context.Context, cl *eth2http.Service) { + const ( + slotEpoch = 1 + index0 = 2 + index1 = 3 + ) + + opts := ð2api.PTCDutiesOpts{ + Epoch: eth2p0.Epoch(slotEpoch), + Indices: []eth2p0.ValidatorIndex{ + eth2p0.ValidatorIndex(index0), + eth2p0.ValidatorIndex(index1), + }, + } + resp, err := cl.PTCDuties(ctx, opts) + require.NoError(t, err) + + res := resp.Data + + require.Len(t, res, 2) + require.Equal(t, int(res[0].Slot), slotEpoch*slotsPerEpoch) + require.Equal(t, int(res[0].ValidatorIndex), index0) + require.Equal(t, int(res[1].Slot), slotEpoch*slotsPerEpoch) + require.Equal(t, int(res[1].ValidatorIndex), index1) + + metadata := resp.Metadata + require.Len(t, metadata, 2) + require.Equal(t, true, metadata["execution_optimistic"]) + require.Equal(t, dependentRoot, metadata["dependent_root"].(eth2p0.Root)) + } + + testRouter(t, handler, callback) + }) + t.Run("proposerduty", func(t *testing.T) { const total = 2 @@ -2289,6 +2339,7 @@ type testHandler struct { SyncCommitteeDutiesFunc func(ctx context.Context, opts *eth2api.SyncCommitteeDutiesOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeDuty], error) SyncCommitteeContributionFunc func(ctx context.Context, opts *eth2api.SyncCommitteeContributionOpts) (*eth2api.Response[*altair.SyncCommitteeContribution], error) PayloadAttestationDataFunc func(ctx context.Context, opts *eth2api.PayloadAttestationDataOpts) (*eth2api.Response[*eth2spec.VersionedPayloadAttestationData], error) + PTCDutiesFunc func(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2api.Response[[]*eth2v1.PTCDuty], error) SubmitPayloadAttMsgsFunc func(ctx context.Context, opts *eth2api.SubmitPayloadAttestationMessagesOpts) error ProxyFunc func(ctx context.Context, req *http.Request) (*http.Response, error) AddressFunc func() string @@ -2307,6 +2358,10 @@ func (h testHandler) SubmitPayloadAttestationMessages(ctx context.Context, opts return h.SubmitPayloadAttMsgsFunc(ctx, opts) } +func (h testHandler) PTCDuties(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2api.Response[[]*eth2v1.PTCDuty], error) { + return h.PTCDutiesFunc(ctx, opts) +} + func (h testHandler) AttesterDuties(ctx context.Context, opts *eth2api.AttesterDutiesOpts) (*eth2api.Response[[]*eth2v1.AttesterDuty], error) { return h.AttesterDutiesFunc(ctx, opts) } diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index c56f74045..85b78b3b0 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -1313,6 +1313,39 @@ func (c Component) AttesterDuties(ctx context.Context, opts *eth2api.AttesterDut return wrapResponseWithMetadata(duties, metadata), nil } +// PTCDuties obtains payload timeliness committee duties with the root public keys replaced by public shares. +func (c Component) PTCDuties(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2api.Response[[]*eth2v1.PTCDuty], error) { + var span trace.Span + + ctx, span = tracer.Start(ctx, "core/validatorapi.PTCDuties") + + span.SetAttributes(attribute.Int64("epoch", int64(opts.Epoch))) + defer span.End() + + eth2Resp, err := c.eth2Cl.PTCDuties(ctx, opts) + if err != nil { + return nil, err + } + + duties := eth2Resp.Data + + // Replace root public keys with public shares. + for _, d := range duties { + if d == nil { + return nil, errors.New("ptc duty cannot be nil") + } + + pubshare, ok := c.getPubShareFunc(d.PubKey) + if !ok { + return nil, errors.New("pubshare not found", z.Str("pubkey", d.PubKey.String())) + } + + d.PubKey = pubshare + } + + return wrapResponseWithMetadata(duties, eth2Resp.Metadata), nil +} + // SyncCommitteeDuties obtains sync committee duties. If validatorIndices is nil it will return all duties for the given epoch. func (c Component) SyncCommitteeDuties(ctx context.Context, opts *eth2api.SyncCommitteeDutiesOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeDuty], error) { var duties []*eth2v1.SyncCommitteeDuty diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go index e2c091a60..fc170e45b 100644 --- a/core/validatorapi/validatorapi_test.go +++ b/core/validatorapi/validatorapi_test.go @@ -1851,6 +1851,33 @@ func TestComponent_Duties(t *testing.T) { require.Equal(t, duties[0].PubKey, eth2Share) }) + t.Run("ptc_duties", func(t *testing.T) { + bmock.PTCDutiesFunc = func(_ context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.PTCDuty, error) { + require.Equal(t, epoch, eth2p0.Epoch(epch)) + require.Equal(t, []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(vIdx)}, indices) + + return []*eth2v1.PTCDuty{{ + PubKey: eth2Pubkey, + ValidatorIndex: vIdx, + }}, nil + } + + // Construct the validator api component + vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, false, 30000000) + require.NoError(t, err) + + opts := ð2api.PTCDutiesOpts{ + Epoch: eth2p0.Epoch(epch), + Indices: []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(vIdx)}, + } + resp, err := vapi.PTCDuties(ctx, opts) + require.NoError(t, err) + + duties := resp.Data + require.Len(t, duties, 1) + require.Equal(t, duties[0].PubKey, eth2Share) + }) + t.Run("sync_committee_duties", func(t *testing.T) { bmock.SyncCommitteeDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.SyncCommitteeDuty, error) { require.Equal(t, epoch, eth2p0.Epoch(epch))