diff --git a/core/fetcher/fetcher.go b/core/fetcher/fetcher.go index 0c71d8418..8d289637e 100644 --- a/core/fetcher/fetcher.go +++ b/core/fetcher/fetcher.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + eth2client "github.com/attestantio/go-eth2-client" eth2api "github.com/attestantio/go-eth2-client/api" eth2spec "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -171,6 +172,11 @@ func (f *Fetcher) Fetch(ctx context.Context, duty core.Duty, defSet core.DutyDef } else if len(unsignedSet) == 0 { // No sync committee contributors found in this slot return nil } + case core.DutyPayloadAttestation: + unsignedSet, err = f.fetchPayloadAttestationData(ctx, duty.Slot, defSet) + if err != nil { + return errors.Wrap(err, "fetch payload attestation data") + } default: return errors.New("unsupported duty type", z.Str("type", duty.Type.String())) } @@ -429,6 +435,39 @@ func (f *Fetcher) fetchProposerData(ctx context.Context, slot uint64, defSet cor return resp, nil } +// fetchPayloadAttestationData returns the fetched payload attestation data for the slot. +// The data is per-slot, not per-validator, so all payload timeliness committee members +// in the arg set attest to the same data. +func (f *Fetcher) fetchPayloadAttestationData(ctx context.Context, slot uint64, defSet core.DutyDefinitionSet) (core.UnsignedDataSet, error) { + opts := ð2api.PayloadAttestationDataOpts{ + Slot: eth2p0.Slot(slot), + } + + eth2Resp, err := f.eth2Cl.PayloadAttestationData(ctx, opts) + if errors.Is(err, eth2client.ErrNoPayloadAttestationData) { + // The beacon node has not seen a block for the slot, so there is nothing to attest. + return nil, errors.New("no block seen for payload attestation slot", z.U64("slot", slot)) + } else if err != nil { + return nil, err + } + + if eth2Resp.Data == nil { + return nil, errors.New("payload attestation data is nil") + } + + data, err := core.NewVersionedPayloadAttestationData(eth2Resp.Data) + if err != nil { + return nil, err + } + + resp := make(core.UnsignedDataSet) + for pubkey := range defSet { + resp[pubkey] = data + } + + return resp, nil +} + // fetchContributionData fetches the sync committee contribution data. func (f *Fetcher) fetchContributionData(ctx context.Context, slot uint64, defSet core.DutyDefinitionSet) (core.UnsignedDataSet, error) { pt := newPubkeysTracker("sync committee contribution") diff --git a/core/fetcher/fetcher_test.go b/core/fetcher/fetcher_test.go index 1b0e7f820..f4758c8c4 100644 --- a/core/fetcher/fetcher_test.go +++ b/core/fetcher/fetcher_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/OffchainLabs/go-bitfield" + eth2client "github.com/attestantio/go-eth2-client" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2spec "github.com/attestantio/go-eth2-client/spec" @@ -843,6 +844,111 @@ func assertRandao(t *testing.T, randao eth2p0.BLSSignature, block core.Versioned } } +func TestFetchPayloadAttestation(t *testing.T) { + const slot = 1 + + pubkeyA := testutil.RandomCorePubKey(t) + pubkeyB := testutil.RandomCorePubKey(t) + + defSet := core.DutyDefinitionSet{ + pubkeyA: core.NewPTCDefinition(ð2v1.PTCDuty{Slot: slot, ValidatorIndex: 2}), + pubkeyB: core.NewPTCDefinition(ð2v1.PTCDuty{Slot: slot, ValidatorIndex: 3}), + } + duty := core.NewPayloadAttestationDuty(slot) + + bmock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + attData := testutil.RandomPayloadAttestationData() + attData.Slot = slot + + bmock.PayloadAttestationDataFunc = func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return ð2spec.VersionedPayloadAttestationData{ + Version: eth2spec.DataVersionGloas, + Gloas: attData, + }, nil + } + + fetch := mustCreateFetcher(t, bmock) + + var subCalled bool + + fetch.Subscribe(func(_ context.Context, resDuty core.Duty, resDataSet core.UnsignedDataSet) error { + subCalled = true + + require.Equal(t, duty, resDuty) + require.Len(t, resDataSet, 2) + + // All committee members attest to the same per-slot data. + for _, pubkey := range []core.PubKey{pubkeyA, pubkeyB} { + data, ok := resDataSet[pubkey].(core.VersionedPayloadAttestationData) + require.True(t, ok) + require.Equal(t, eth2spec.DataVersionGloas, data.Version) + require.Equal(t, attData, data.Gloas) + } + + return nil + }) + + require.NoError(t, fetch.Fetch(t.Context(), duty, defSet)) + require.True(t, subCalled) +} + +func TestFetchPayloadAttestationError(t *testing.T) { + const slot = 1 + + defSet := core.DutyDefinitionSet{ + testutil.RandomCorePubKey(t): core.NewPTCDefinition(ð2v1.PTCDuty{Slot: slot, ValidatorIndex: 2}), + } + duty := core.NewPayloadAttestationDuty(slot) + + tests := []struct { + name string + dataFunc func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) + errContains string + }{ + { + name: "no block seen for slot", + dataFunc: func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return nil, eth2client.ErrNoPayloadAttestationData + }, + errContains: "no block seen for payload attestation slot", + }, + { + name: "nil data", + dataFunc: func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return nil, nil //nolint:nilnil // Mimics a beacon node responding 200 with an empty body. + }, + errContains: "payload attestation data is nil", + }, + { + name: "invalid version", + dataFunc: func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return ð2spec.VersionedPayloadAttestationData{Version: eth2spec.DataVersionElectra}, nil + }, + errContains: "unknown version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bmock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + bmock.PayloadAttestationDataFunc = test.dataFunc + + fetch := mustCreateFetcher(t, bmock) + fetch.Subscribe(func(context.Context, core.Duty, core.UnsignedDataSet) error { + require.Fail(t, "unexpected subscriber call") + return nil + }) + + err = fetch.Fetch(t.Context(), duty, defSet) + require.ErrorContains(t, err, test.errContains) + }) + } +} + // blsSigFromHex returns the BLS signature from the input hex signature. func blsSigFromHex(t *testing.T, sig string) eth2p0.BLSSignature { t.Helper()