diff --git a/testutil/beaconmock/beaconmock.go b/testutil/beaconmock/beaconmock.go index 38f5e8e34..69b38e06f 100644 --- a/testutil/beaconmock/beaconmock.go +++ b/testutil/beaconmock/beaconmock.go @@ -351,7 +351,7 @@ func (m Mock) PTCDuties(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2 return nil, err } - return ð2api.Response[[]*eth2v1.PTCDuty]{Data: duties, Metadata: make(map[string]any)}, nil + return wrapResponseWithMetadata(duties), nil } func (m Mock) PayloadAttestationData(ctx context.Context, opts *eth2api.PayloadAttestationDataOpts) (*eth2api.Response[*eth2spec.VersionedPayloadAttestationData], error) { diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 4afccb5aa..f1770352d 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -560,6 +560,54 @@ func WithDeterministicSyncCommDuties(n, k int) Option { } } +// WithDeterministicPTCDuties configures the mock to override PTCDutiesFunc to return payload +// timeliness committee duties for all validators on every slot of the first N epochs in every K epochs. +func WithDeterministicPTCDuties(n, k int) Option { + return func(mock *Mock) { + mock.PTCDutiesFunc = func(ctx context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.PTCDuty, error) { + if int(epoch)%k >= n { + return nil, nil + } + + opts := ð2api.ValidatorsOpts{ + State: "", + Indices: indices, + } + + eth2Resp, err := mock.Validators(ctx, opts) + if err != nil { + return nil, err + } + + vals := eth2Resp.Data + + slotsPerEpoch, err := mock.SlotsPerEpoch(ctx) + if err != nil { + return nil, err + } + + var resp []*eth2v1.PTCDuty + + for _, index := range indices { + val, ok := vals[index] + if !ok { + continue + } + + for s := range slotsPerEpoch { + resp = append(resp, ð2v1.PTCDuty{ + PubKey: val.Validator.PublicKey, + ValidatorIndex: index, + Slot: eth2p0.Slot(uint64(epoch)*slotsPerEpoch + s), + }) + } + } + + return resp, nil + } + } +} + // WithSyncCommitteeSize configures the http mock with the provided sync committee size. func WithSyncCommitteeSize(size int) Option { return func(mock *Mock) { diff --git a/testutil/integration/simnet_test.go b/testutil/integration/simnet_test.go index 21b5a7ea0..b4f5f08d7 100644 --- a/testutil/integration/simnet_test.go +++ b/testutil/integration/simnet_test.go @@ -68,6 +68,12 @@ func TestSimnetDuties(t *testing.T) { duties: []core.DutyType{core.DutyPrepareSyncContribution, core.DutySyncMessage, core.DutySyncContribution}, vcType: vcVmock, }, + { + name: "payload attestation with mock VCs", + scheduledType: core.DutyPayloadAttestation, + duties: []core.DutyType{core.DutyPayloadAttestation}, + vcType: vcVmock, + }, // TODO(andrei): Need a redesign due to how builder registration is handled now. // { // name: "builder registration with mock VCs", @@ -125,6 +131,15 @@ func TestSimnetDuties(t *testing.T) { args.BMockOpts = append(args.BMockOpts, beaconmock.WithDeterministicSyncCommDuties(2, 2)) } + if test.scheduledType == core.DutyPayloadAttestation { + // PTC duties only exist from gloas onwards, activate the fork and enable duties for all epochs. + args.BMockOpts = append(args.BMockOpts, + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "0"), + beaconmock.WithDeterministicPTCDuties(2, 2), + ) + } + expect := newSimnetExpect(args.N, test.duties...) testSimnet(t, args, expect) }) diff --git a/testutil/integration/timing_test.go b/testutil/integration/timing_test.go new file mode 100644 index 000000000..b8a01174b --- /dev/null +++ b/testutil/integration/timing_test.go @@ -0,0 +1,189 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package integration_test + +import ( + "context" + "slices" + "strconv" + "sync" + "testing" + "time" + + eth2spec "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/testutil/beaconmock" +) + +// TestSimnetDutyTimingGloasMigration asserts that intra-slot duty timings migrate from +// pre-gloas thirds to gloas quarters at the fork boundary, by observing the beacon node +// side of a full cluster with mock VCs performing all duties across the fork transition. +func TestSimnetDutyTimingGloasMigration(t *testing.T) { + skipIfDisabled(t) + + // Whole seconds only, since it is published as SECONDS_PER_SLOT. + const slotDuration = time.Second + + args := newSimnetArgs(t) + args.VMocks = true + + // The simnet app derives the beaconmock genesis from the cluster fork version. + genesis, err := eth2util.ForkVersionToGenesisTime(args.Lock.ForkVersion) + require.NoError(t, err) + + // Simnet uses one slot per epoch, so epochs equal slots. + startSlot := uint64(time.Since(genesis)/slotDuration) + 1 + // Startup margin so the slow pre-fork duties (sync contributions) are observed. + forkSlot := startSlot + 12 + forkTime := genesis.Add(time.Duration(forkSlot) * slotDuration) + + t.Logf("genesis=%v startSlot=%d forkSlot=%d forkTime=%v", genesis, startSlot, forkSlot, forkTime) + + rec := newTimingRecorder(genesis, slotDuration, forkSlot) + args.BMockOpts = append(args.BMockOpts, + beaconmock.WithSlotDuration(slotDuration), + beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), + beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", strconv.FormatUint(forkSlot, 10)), + // Make every validator an aggregator so aggregations happen every slot. + beaconmock.WithSpecOverride("TARGET_AGGREGATORS_PER_COMMITTEE", "1000000"), + beaconmock.WithSpecOverride("TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE", "1000000"), + beaconmock.WithNoProposerDuties(), + beaconmock.WithDeterministicSyncCommDuties(2, 2), + beaconmock.WithDeterministicPTCDuties(2, 2), + rec.option(), + ) + + expect := newSimnetExpect(args.N, + core.DutyPrepareAggregator, core.DutyAttester, core.DutyAggregator, + core.DutyPrepareSyncContribution, core.DutySyncMessage, core.DutySyncContribution, + core.DutyPayloadAttestation, // Only completes once gloas activates, keeping the test alive across the fork. + ) + testSimnet(t, args, expect) + + require.Greater(t, time.Now(), forkTime, "test finished before the gloas fork activated") + + // Assertion windows use spec basis points and the millisecond rounding of core.NewSlotOffsetFunc. + bps := func(bps int64) time.Duration { + return time.Duration(int64(slotDuration) * bps / 10000).Round(time.Millisecond) + } + + // Pre-gloas duties are due at thirds of the slot. + rec.assertMinOffset(t, "attestation_data", false, bps(3333), bps(6667)) + rec.assertMinOffset(t, "aggregate_attestation", false, bps(6667), slotDuration) + rec.assertMinOffset(t, "sync_message", false, bps(3333), bps(6667)) + rec.assertMinOffset(t, "sync_contribution", false, bps(6667), slotDuration) + rec.assertNone(t, "payload_attestation_data", false) + + // Gloas duties are due at quarters of the slot. + rec.assertMinOffset(t, "attestation_data", true, bps(2500), bps(3333)) + rec.assertMinOffset(t, "aggregate_attestation", true, bps(5000), bps(6667)) + rec.assertMinOffset(t, "sync_message", true, bps(2500), bps(3333)) + rec.assertMinOffset(t, "sync_contribution", true, bps(5000), bps(6667)) + rec.assertMinOffset(t, "payload_attestation_data", true, bps(5000), bps(7500)) +} + +// timingRecorder records the intra-slot offsets at which beaconmock endpoints are hit. +type timingRecorder struct { + genesis time.Time + slotDuration time.Duration + forkSlot uint64 + + mu sync.Mutex + offsets map[string][]time.Duration // Keyed by "|pre" or "|post". +} + +func newTimingRecorder(genesis time.Time, slotDuration time.Duration, forkSlot uint64) *timingRecorder { + return &timingRecorder{ + genesis: genesis, + slotDuration: slotDuration, + forkSlot: forkSlot, + offsets: make(map[string][]time.Duration), + } +} + +func (r *timingRecorder) record(metric string, slot eth2p0.Slot) { + offset := time.Since(r.genesis.Add(time.Duration(slot) * r.slotDuration)) + + key := metric + "|pre" + if uint64(slot) >= r.forkSlot { + key = metric + "|post" + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.offsets[key] = append(r.offsets[key], offset) +} + +func (r *timingRecorder) get(metric string, postFork bool) []time.Duration { + key := metric + "|pre" + if postFork { + key = metric + "|post" + } + + r.mu.Lock() + defer r.mu.Unlock() + + return slices.Clone(r.offsets[key]) +} + +// assertMinOffset asserts that the earliest observed offset of the metric is within +// [minOffset, maxOffset), since delays only ever push observations later. +func (r *timingRecorder) assertMinOffset(t *testing.T, metric string, postFork bool, minOffset, maxOffset time.Duration) { + t.Helper() + + offsets := r.get(metric, postFork) + require.NotEmptyf(t, offsets, "no %v observations (post_fork=%v)", metric, postFork) + + earliest := slices.Min(offsets) + require.GreaterOrEqualf(t, earliest, minOffset, "%v triggered before its due offset (post_fork=%v)", metric, postFork) + require.Lessf(t, earliest, maxOffset, "%v triggered too late for its due offset (post_fork=%v)", metric, postFork) +} + +func (r *timingRecorder) assertNone(t *testing.T, metric string, postFork bool) { + t.Helper() + require.Emptyf(t, r.get(metric, postFork), "unexpected %v observations (post_fork=%v)", metric, postFork) +} + +// option returns a beaconmock option wrapping the duty related endpoints with timing recording. +func (r *timingRecorder) option() beaconmock.Option { + return func(mock *beaconmock.Mock) { + attInner := mock.AttestationDataFunc + mock.AttestationDataFunc = func(ctx context.Context, slot eth2p0.Slot, commIdx eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) { + r.record("attestation_data", slot) + return attInner(ctx, slot, commIdx) + } + + aggInner := mock.AggregateAttestationFunc + mock.AggregateAttestationFunc = func(ctx context.Context, slot eth2p0.Slot, root eth2p0.Root) (*eth2spec.VersionedAttestation, error) { + r.record("aggregate_attestation", slot) + return aggInner(ctx, slot, root) + } + + syncMsgInner := mock.SubmitSyncCommitteeMessagesFunc + mock.SubmitSyncCommitteeMessagesFunc = func(ctx context.Context, messages []*altair.SyncCommitteeMessage) error { + for _, msg := range messages { + r.record("sync_message", msg.Slot) + } + + return syncMsgInner(ctx, messages) + } + + contribInner := mock.SyncCommitteeContributionFunc + mock.SyncCommitteeContributionFunc = func(ctx context.Context, slot eth2p0.Slot, subcommIdx uint64, root eth2p0.Root) (*altair.SyncCommitteeContribution, error) { + r.record("sync_contribution", slot) + return contribInner(ctx, slot, subcommIdx, root) + } + + padInner := mock.PayloadAttestationDataFunc + mock.PayloadAttestationDataFunc = func(ctx context.Context, slot eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + r.record("payload_attestation_data", slot) + return padInner(ctx, slot) + } + } +} diff --git a/testutil/validatormock/component.go b/testutil/validatormock/component.go index fe90ef380..c97eaebf0 100644 --- a/testutil/validatormock/component.go +++ b/testutil/validatormock/component.go @@ -304,6 +304,10 @@ func (m *Component) runDuty(ctx context.Context, duty core.Duty) error { if _, err = syncComm.Aggregate(ctx, eth2Slot); err != nil { // Rename to sync.Comm.AggregateSyncContribution return err } + case core.DutyPayloadAttestation: + if err = PayloadAttest(ctx, eth2Cl, m.signFunc, eth2Slot); err != nil { + return err + } case core.DutyBuilderRegistration: // Expected duty, but no action needed in validatormock. default: @@ -441,6 +445,7 @@ var dutyStartTimeFuncsByDuty = map[core.DutyType][]dutyStartTimeFunc{ core.DutyPrepareSyncContribution: {slotStartTime}, core.DutySyncMessage: {dutyOffset(core.DutySyncMessage)}, core.DutySyncContribution: {dutyOffset(core.DutySyncContribution)}, + core.DutyPayloadAttestation: {dutyOffset(core.DutyPayloadAttestation)}, } // startOfPrevEpoch returns the start time of the previous epoch. diff --git a/testutil/validatormock/payloadattest.go b/testutil/validatormock/payloadattest.go new file mode 100644 index 000000000..c0c7444ce --- /dev/null +++ b/testutil/validatormock/payloadattest.go @@ -0,0 +1,117 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package validatormock + +import ( + "context" + + 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" + "github.com/attestantio/go-eth2-client/spec/gloas" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/eth2util/signing" +) + +// PayloadAttest performs the payload timeliness committee duty for the provided slot. +// It is stateless and does nothing if no active validator is a payload timeliness +// committee member for the slot. +func PayloadAttest(ctx context.Context, eth2Cl eth2wrap.Client, signFunc SignFunc, slot eth2p0.Slot) error { + valMap, err := eth2Cl.ActiveValidators(ctx) + if err != nil { + return err + } + + _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) + if err != nil { + return err + } + + epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + + var indexes []eth2p0.ValidatorIndex + for index := range valMap { + indexes = append(indexes, index) + } + + eth2Resp, err := eth2Cl.PTCDuties(ctx, ð2api.PTCDutiesOpts{ + Epoch: epoch, + Indices: indexes, + }) + if err != nil { + return err + } + + var duties []*eth2v1.PTCDuty + + for _, duty := range eth2Resp.Data { + if duty.Slot == slot { + duties = append(duties, duty) + } + } + + if len(duties) == 0 { + return nil + } + + // The payload attestation data is per-slot, all committee members attest to the same data. + dataResp, err := eth2Cl.PayloadAttestationData(ctx, ð2api.PayloadAttestationDataOpts{Slot: slot}) + if errors.Is(err, eth2client.ErrNoPayloadAttestationData) { + // The beacon node has seen no block for the slot, so there is nothing to attest. + return nil + } else if err != nil { + return err + } + + versioned := dataResp.Data + if versioned == nil { + return errors.New("versioned payload attestation data is nil") + } + + var data *gloas.PayloadAttestationData + + switch versioned.Version { + case eth2spec.DataVersionGloas: + data = versioned.Gloas + default: + return errors.New("unknown payload attestation data version") + } + + if data == nil { + return errors.New("no gloas payload attestation data") + } + + root, err := data.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "hash payload attestation data") + } + + sigData, err := signing.GetDataRoot(ctx, eth2Cl, signing.DomainPTCAttester, epoch, root) + if err != nil { + return err + } + + var msgs []*eth2spec.VersionedPayloadAttestationMessage + + for _, duty := range duties { + sig, err := signFunc(duty.PubKey, sigData[:]) + if err != nil { + return err + } + + msgs = append(msgs, ð2spec.VersionedPayloadAttestationMessage{ + Version: versioned.Version, + Gloas: &gloas.PayloadAttestationMessage{ + ValidatorIndex: duty.ValidatorIndex, + Data: data, + Signature: sig, + }, + }) + } + + return eth2Cl.SubmitPayloadAttestationMessages(ctx, ð2api.SubmitPayloadAttestationMessagesOpts{Messages: msgs}) +} diff --git a/testutil/validatormock/payloadattest_test.go b/testutil/validatormock/payloadattest_test.go new file mode 100644 index 000000000..43363ad5e --- /dev/null +++ b/testutil/validatormock/payloadattest_test.go @@ -0,0 +1,120 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package validatormock_test + +import ( + "context" + "testing" + + 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" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/validatormock" +) + +func TestPayloadAttest(t *testing.T) { + const slot = 123 + + valSet := beaconmock.ValidatorSetA + + var ( + dutyIdx eth2p0.ValidatorIndex + dutyPubkey eth2p0.BLSPubKey + ) + + for idx, val := range valSet { + dutyIdx = idx + dutyPubkey = val.Validator.PublicKey + + break + } + + attData := testutil.RandomVersionedPayloadAttestationData() + attData.Gloas.Slot = slot + + sig := testutil.RandomEth2Signature() + signFunc := func(key eth2p0.BLSPubKey, _ []byte) (eth2p0.BLSSignature, error) { //nolint:unparam // The SignFunc signature requires an error. + require.Equal(t, dutyPubkey, key) + return sig, nil + } + + newMock := func(t *testing.T) beaconmock.Mock { + t.Helper() + + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet)) + require.NoError(t, err) + + bmock.PTCDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.PTCDuty, error) { + return []*eth2v1.PTCDuty{{ + PubKey: dutyPubkey, + Slot: slot, + ValidatorIndex: dutyIdx, + }}, nil + } + bmock.PayloadAttestationDataFunc = func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return attData, nil + } + + return bmock + } + + t.Run("submit message", func(t *testing.T) { + bmock := newMock(t) + + var submitted *eth2api.SubmitPayloadAttestationMessagesOpts + + bmock.SubmitPayloadAttestationMessagesFunc = func(_ context.Context, opts *eth2api.SubmitPayloadAttestationMessagesOpts) error { + submitted = opts + return nil + } + + require.NoError(t, validatormock.PayloadAttest(t.Context(), bmock, signFunc, slot)) + require.NotNil(t, submitted) + require.Len(t, submitted.Messages, 1) + + msg := submitted.Messages[0] + require.Equal(t, eth2spec.DataVersionGloas, msg.Version) + require.Equal(t, dutyIdx, msg.Gloas.ValidatorIndex) + require.Equal(t, attData.Gloas, msg.Gloas.Data) + require.Equal(t, sig, msg.Gloas.Signature) + }) + + t.Run("no duty for slot", func(t *testing.T) { + bmock := newMock(t) + bmock.SubmitPayloadAttestationMessagesFunc = func(context.Context, *eth2api.SubmitPayloadAttestationMessagesOpts) error { + require.Fail(t, "unexpected submission") + return nil + } + + require.NoError(t, validatormock.PayloadAttest(t.Context(), bmock, signFunc, slot+1)) + }) + + t.Run("no block seen", func(t *testing.T) { + bmock := newMock(t) + bmock.PayloadAttestationDataFunc = func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return nil, eth2client.ErrNoPayloadAttestationData + } + bmock.SubmitPayloadAttestationMessagesFunc = func(context.Context, *eth2api.SubmitPayloadAttestationMessagesOpts) error { + require.Fail(t, "unexpected submission") + return nil + } + + require.NoError(t, validatormock.PayloadAttest(t.Context(), bmock, signFunc, slot)) + }) + + t.Run("unknown version", func(t *testing.T) { + bmock := newMock(t) + bmock.PayloadAttestationDataFunc = func(context.Context, eth2p0.Slot) (*eth2spec.VersionedPayloadAttestationData, error) { + return ð2spec.VersionedPayloadAttestationData{Version: eth2spec.DataVersionElectra}, nil + } + + require.ErrorContains(t, validatormock.PayloadAttest(t.Context(), bmock, signFunc, slot), + "unknown payload attestation data version") + }) +}