Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion testutil/beaconmock/beaconmock.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ func (m Mock) PTCDuties(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2
return nil, err
}

return &eth2api.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) {
Expand Down
48 changes: 48 additions & 0 deletions testutil/beaconmock/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := &eth2api.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, &eth2v1.PTCDuty{
PubKey: val.Validator.PublicKey,
ValidatorIndex: index,
Slot: eth2p0.Slot(uint64(epoch)*slotsPerEpoch + s),
})
}
Comment thread
KaloyanTanev marked this conversation as resolved.
}

return resp, nil
}
}
}

// WithSyncCommitteeSize configures the http mock with the provided sync committee size.
func WithSyncCommitteeSize(size int) Option {
return func(mock *Mock) {
Expand Down
15 changes: 15 additions & 0 deletions testutil/integration/simnet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
})
Expand Down
5 changes: 5 additions & 0 deletions testutil/validatormock/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions testutil/validatormock/payloadattest.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
KaloyanTanev marked this conversation as resolved.

_, 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, &eth2api.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, &eth2api.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")
}
Comment thread
Copilot marked this conversation as resolved.

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, &eth2spec.VersionedPayloadAttestationMessage{
Version: versioned.Version,
Gloas: &gloas.PayloadAttestationMessage{
ValidatorIndex: duty.ValidatorIndex,
Data: data,
Signature: sig,
},
})
}

return eth2Cl.SubmitPayloadAttestationMessages(ctx, &eth2api.SubmitPayloadAttestationMessagesOpts{Messages: msgs})
}
120 changes: 120 additions & 0 deletions testutil/validatormock/payloadattest_test.go
Original file line number Diff line number Diff line change
@@ -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) {

Check failure on line 42 in testutil/validatormock/payloadattest_test.go

View workflow job for this annotation

GitHub Actions / golangci

TestPayloadAttest$1 - result 1 (error) is always nil (unparam)
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 &eth2spec.VersionedPayloadAttestationData{Version: eth2spec.DataVersionElectra}, nil
}

require.ErrorContains(t, validatormock.PayloadAttest(t.Context(), bmock, signFunc, slot),
"unknown payload attestation data version")
})
}
Loading