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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ gloas:
- default controller.max-attestation-delay, controller.attestation-aggregation-delay, controller.max-sync-committee-message-delay and controller.sync-committee-aggregation-delay to 0; the hardcoded defaults made the spec-derived deadlines unreachable
- add the payload timeliness committee duty: fetch PTC duties for the epoch, group each slot's validators into one job scheduled inside the gloas payload timing window, and vote on whether the slot's execution payload was revealed on time
- submit versioned payload attestations through the immediate, multinode and null submitters, batching signatures for validators that share a slot
- obtain payload attestation data from a dedicated multiclient, configurable with strategies.payloadattestationdata.beacon-node-addresses
- obtain payload attestation data from a dedicated multiclient (the 'simple' style), configurable with strategies.payloadattestationdata.beacon-node-addresses
- add first and majority payloadattestationdata strategies, selected with strategies.payloadattestationdata.style; the majority strategy breaks a tie in favour of the payload-present vote, rejects responses that disagree on the beacon block root, and proceeds with the responses received when the timeout fires rather than discarding them
- default strategies.payloadattestationdata.timeout to 1s, tighter than the global timeout because payload attestation data is due 75% of the way through the slot; the per-style timeouts and beacon-node-addresses inherit from it
- update go-eth2-client to a gloas pseudo-version
- satisfy the attgo struct field order and comment capitalisation rules across services and strategies

Expand Down
42 changes: 42 additions & 0 deletions clients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,48 @@ func TestPayloadAttesterUsesConfiguredPayloadAttestationDataProviders(t *testing
require.Zero(t, globalRequests.Load())
}

func TestFirstPayloadAttestationDataStrategyRejectsInvalidResponses(t *testing.T) {
ctx := context.Background()
invalidAddress := "http://payload-data-invalid.test"
validAddress := "http://payload-data-valid.test"
invalid, err := mockconsensusclient.New(ctx)
require.NoError(t, err)
invalid.PayloadAttestationDataFunc = func(context.Context, *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) {
return &api.Response[*spec.VersionedPayloadAttestationData]{}, nil
}
valid, err := mockconsensusclient.New(ctx)
require.NoError(t, err)
valid.PayloadAttestationDataFunc = func(_ context.Context, opts *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) {
return &api.Response[*spec.VersionedPayloadAttestationData]{
Data: &spec.VersionedPayloadAttestationData{
Version: spec.DataVersionGloas,
Gloas: &gloas.PayloadAttestationData{Slot: opts.Slot},
},
}, nil
}
viper.Set("strategies.payloadattestationdata.style", "first")
viper.Set("strategies.payloadattestationdata.first.timeout", time.Second)
viper.Set("strategies.payloadattestationdata.first.beacon-node-addresses", []string{invalidAddress, validAddress})
knownClientsMu.Lock()
knownClients[invalidAddress] = invalid
knownClients[validAddress] = valid
knownClientsMu.Unlock()
t.Cleanup(func() {
viper.Reset()
knownClientsMu.Lock()
delete(knownClients, invalidAddress)
delete(knownClients, validAddress)
knownClientsMu.Unlock()
})

provider, err := selectPayloadAttestationDataProvider(ctx, null.New())
require.NoError(t, err)
response, err := provider.PayloadAttestationData(ctx, &api.PayloadAttestationDataOpts{Slot: 12})
require.NoError(t, err)
require.NotNil(t, response.Data)
require.Equal(t, phase0.Slot(12), response.Data.Gloas.Slot)
}

type payloadAttestationDataSigner struct{}

func (*payloadAttestationDataSigner) SignPayloadAttestationData(_ context.Context, accounts []e2wtypes.Account, _ *gloas.PayloadAttestationData) ([]phase0.BLSSignature, error) {
Expand Down
14 changes: 13 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,23 @@ strategies:
# bid-gap is the gap between receiving a response from a relay and querying it again.
bid-gap: '100ms'
# The payloadattestationdata strategy obtains payload attestation data for payload timeliness committee duties.
# It uses a single multiclient; there is no style to select.
payloadattestationdata:
# style can be 'simple', 'first', or 'majority'. Unknown or absent values use 'simple'.
style: 'simple'
# beacon-node-addresses are the addresses from which to receive payload attestation data, falling back to the
# top-level beacon-node-addresses if not set.
beacon-node-addresses: ['localhost:4000', 'localhost:5051', 'localhost:5052']
first:
# beacon-node-addresses and timeout fall back to payloadattestationdata and then the top-level setting.
beacon-node-addresses: ['localhost:4000', 'localhost:5051', 'localhost:5052']
timeout: '1s'
majority:
# beacon-node-addresses and timeout fall back to payloadattestationdata and then the top-level setting.
beacon-node-addresses: ['localhost:4000', 'localhost:5051', 'localhost:5052']
timeout: '1s'
# threshold is the minimum number of matching valid responses. 0 is valid.
# At the timeout the strategy proceeds with the responses it has received.
threshold: 0
# The signedbeaconblock strategy obtains the signed beacon blocks from multiple beacon nodes.
signedbeaconblock:
# style can be 'first'. If not defined, the 'first' style will be used.
Expand Down
68 changes: 61 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ import (
"github.com/attestantio/vouch/strategies/builderbid"
bestbuilderbidstrategy "github.com/attestantio/vouch/strategies/builderbid/best"
deadlinebuilderbidstrategy "github.com/attestantio/vouch/strategies/builderbid/deadline"
firstpayloadattestationdatastrategy "github.com/attestantio/vouch/strategies/payloadattestationdata/first"
majoritypayloadattestationdatastrategy "github.com/attestantio/vouch/strategies/payloadattestationdata/majority"
firstsignedbeaconblockstrategy "github.com/attestantio/vouch/strategies/signedbeaconblock/first"
bestsynccommitteecontributionstrategy "github.com/attestantio/vouch/strategies/synccommitteecontribution/best"
firstsynccommitteecontributionstrategy "github.com/attestantio/vouch/strategies/synccommitteecontribution/first"
Expand Down Expand Up @@ -274,6 +276,9 @@ func fetchConfig() error {
viper.SetDefault("beaconblockproposer.builder-boost-factor", 91)
viper.SetDefault("strategies.builderbid.deadline.deadline", time.Second)
viper.SetDefault("strategies.builderbid.deadline.bid-gap", 100*time.Millisecond)
// Payload attestation data is due 75% of the way through the slot, so default tighter than the
// global timeout. Set at the strategy level so that the per-style timeouts still inherit from it.
viper.SetDefault("strategies.payloadattestationdata.timeout", time.Second)
viper.SetDefault("submitter.style", "multinode")
viper.SetDefault("multiinstance.static-delay.attester-delay", time.Second)
viper.SetDefault("multiinstance.static-delay.proposer-delay", 2*time.Second)
Expand Down Expand Up @@ -1698,15 +1703,11 @@ func startPayloadAttester(ctx context.Context,
if !ok {
return nil, nil
}
payloadAttestationDataClient, err := fetchMultiClient(ctx, monitor,
"payloadattestationdata",
util.BeaconNodeAddressesForPayloadAttestationData(),
)
payloadAttestationDataProvider, err := selectPayloadAttestationDataProvider(ctx, monitor)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch clients for payload attestation data")
return nil, errors.Wrap(err, "failed to obtain payload attestation data provider")
}
payloadAttestationDataProvider, ok := payloadAttestationDataClient.(eth2client.PayloadAttestationDataProvider)
if !ok {
if payloadAttestationDataProvider == nil {
return nil, nil
}

Expand All @@ -1725,6 +1726,59 @@ func startPayloadAttester(ctx context.Context,
return service, nil
}

func selectPayloadAttestationDataProvider(ctx context.Context, monitor metrics.Service) (eth2client.PayloadAttestationDataProvider, error) {
switch viper.GetString("strategies.payloadattestationdata.style") {
case "first":
providers, err := genericAddressToClientMapper[eth2client.PayloadAttestationDataProvider](ctx, monitor,
"strategies.payloadattestationdata.first",
"first payload attestation data strategy")
if err != nil {
return nil, err
}
provider, err := firstpayloadattestationdatastrategy.New(ctx,
firstpayloadattestationdatastrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)),
firstpayloadattestationdatastrategy.WithLogLevel(util.LogLevel("strategies.payloadattestationdata.first")),
firstpayloadattestationdatastrategy.WithPayloadAttestationDataProviders(providers),
firstpayloadattestationdatastrategy.WithTimeout(util.Timeout("strategies.payloadattestationdata.first")),
)
if err != nil {
return nil, errors.Wrap(err, "failed to start first payload attestation data strategy")
}
return provider, nil
case "majority":
providers, err := genericAddressToClientMapper[eth2client.PayloadAttestationDataProvider](ctx, monitor,
"strategies.payloadattestationdata.majority",
"majority payload attestation data strategy")
if err != nil {
return nil, err
}
provider, err := majoritypayloadattestationdatastrategy.New(ctx,
majoritypayloadattestationdatastrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)),
majoritypayloadattestationdatastrategy.WithLogLevel(util.LogLevel("strategies.payloadattestationdata.majority")),
majoritypayloadattestationdatastrategy.WithPayloadAttestationDataProviders(providers),
majoritypayloadattestationdatastrategy.WithTimeout(util.Timeout("strategies.payloadattestationdata.majority")),
majoritypayloadattestationdatastrategy.WithThreshold(viper.GetInt("strategies.payloadattestationdata.majority.threshold")),
)
if err != nil {
return nil, errors.Wrap(err, "failed to start majority payload attestation data strategy")
}
return provider, nil
default:
payloadAttestationDataClient, err := fetchMultiClient(ctx, monitor,
"payloadattestationdata",
util.BeaconNodeAddressesForPayloadAttestationData(),
)
if err != nil {
return nil, err
}
provider, ok := payloadAttestationDataClient.(eth2client.PayloadAttestationDataProvider)
if !ok {
return nil, nil
}
return provider, nil
}
}

func genericAddressToClientMapper[T any](ctx context.Context, monitor metrics.Service, path, description string) (map[string]T, error) {
addressToClientMap := make(map[string]T)
for _, address := range util.BeaconNodeAddresses(path) {
Expand Down
86 changes: 86 additions & 0 deletions strategies/payloadattestationdata/first/parameters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright © 2026 Attestant Limited.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package first obtains payload attestation data from multiple nodes and selects the first valid response.
package first

import (
"time"

eth2client "github.com/attestantio/go-eth2-client"
"github.com/attestantio/vouch/services/metrics"
nullmetrics "github.com/attestantio/vouch/services/metrics/null"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)

type parameters struct {
logLevel zerolog.Level
clientMonitor metrics.ClientMonitor
payloadAttestationDataProviders map[string]eth2client.PayloadAttestationDataProvider
timeout time.Duration
}

// Parameter is the interface for service parameters.
type Parameter interface {
apply(parameters *parameters)
}

type parameterFunc func(*parameters)

func (f parameterFunc) apply(parameters *parameters) {
f(parameters)
}

// WithLogLevel sets the log level for the module.
func WithLogLevel(logLevel zerolog.Level) Parameter {
return parameterFunc(func(parameters *parameters) { parameters.logLevel = logLevel })
}

// WithClientMonitor sets the client monitor for the service.
func WithClientMonitor(monitor metrics.ClientMonitor) Parameter {
return parameterFunc(func(parameters *parameters) { parameters.clientMonitor = monitor })
}

// WithPayloadAttestationDataProviders sets the payload attestation data providers.
func WithPayloadAttestationDataProviders(providers map[string]eth2client.PayloadAttestationDataProvider) Parameter {
return parameterFunc(func(parameters *parameters) { parameters.payloadAttestationDataProviders = providers })
}

// WithTimeout sets the timeout for requests.
func WithTimeout(timeout time.Duration) Parameter {
return parameterFunc(func(parameters *parameters) { parameters.timeout = timeout })
}

func parseAndCheckParameters(params ...Parameter) (*parameters, error) {
parameters := &parameters{
logLevel: zerolog.GlobalLevel(),
clientMonitor: nullmetrics.New(),
timeout: time.Second,
}
for _, param := range params {
if param != nil {
param.apply(parameters)
}
}
if parameters.clientMonitor == nil {
return nil, errors.New("no client monitor specified")
}
if len(parameters.payloadAttestationDataProviders) == 0 {
return nil, errors.New("no payload attestation data providers specified")
}
if parameters.timeout <= 0 {
return nil, errors.New("timeout must be positive")
}
return parameters, nil
}
125 changes: 125 additions & 0 deletions strategies/payloadattestationdata/first/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright © 2026 Attestant Limited.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package first

import (
"context"
"time"

eth2client "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/api"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/vouch/services/metrics"
"github.com/pkg/errors"
"github.com/rs/zerolog"
zerologger "github.com/rs/zerolog/log"
)

// Service provides payload attestation data.
type Service struct {
log zerolog.Logger
clientMonitor metrics.ClientMonitor
payloadAttestationDataProviders map[string]eth2client.PayloadAttestationDataProvider
timeout time.Duration
}

type payloadAttestationDataResult struct {
provider string
response *api.Response[*spec.VersionedPayloadAttestationData]
err error
}

// New creates a payload attestation data strategy.
func New(_ context.Context, params ...Parameter) (*Service, error) {
parameters, err := parseAndCheckParameters(params...)
if err != nil {
return nil, errors.Wrap(err, "problem with parameters")
}
log := zerologger.With().Str("strategy", "payloadattestationdata").Str("impl", "first").Logger()
if parameters.logLevel != log.GetLevel() {
log = log.Level(parameters.logLevel)
}
return &Service{
log: log,
clientMonitor: parameters.clientMonitor,
payloadAttestationDataProviders: parameters.payloadAttestationDataProviders,
timeout: parameters.timeout,
}, nil
}

// PayloadAttestationData obtains the first valid payload attestation data response.
func (s *Service) PayloadAttestationData(ctx context.Context, opts *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) {
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()

started := time.Now()
results := s.issuePayloadAttestationDataRequests(ctx, opts, started)

for range s.payloadAttestationDataProviders {
select {
case <-ctx.Done():
// Deadline reached; prefer a response that already arrived over the expired context.
for len(results) > 0 {
if response := s.selectedPayloadAttestationData(opts, <-results, started); response != nil {
return response, nil
}
}

return nil, errors.Wrap(ctx.Err(), "failed to obtain payload attestation data")
case result := <-results:
if response := s.selectedPayloadAttestationData(opts, result, started); response != nil {
return response, nil
}
}
}

if ctx.Err() != nil {
return nil, errors.Wrap(ctx.Err(), "failed to obtain payload attestation data")
}

return nil, errors.New("no valid payload attestation data received")
}

func (s *Service) issuePayloadAttestationDataRequests(ctx context.Context, opts *api.PayloadAttestationDataOpts, started time.Time) <-chan payloadAttestationDataResult {
results := make(chan payloadAttestationDataResult, len(s.payloadAttestationDataProviders))
for name, provider := range s.payloadAttestationDataProviders {
go func(providerName string, provider eth2client.PayloadAttestationDataProvider) {
response, err := provider.PayloadAttestationData(ctx, opts)
s.clientMonitor.ClientOperation(providerName, "payload attestation data", err == nil, time.Since(started))
results <- payloadAttestationDataResult{provider: providerName, response: response, err: err}
}(name, provider)
}

return results
}

// selectedPayloadAttestationData returns the response if the result is valid for the request, otherwise nil.
func (s *Service) selectedPayloadAttestationData(opts *api.PayloadAttestationDataOpts, result payloadAttestationDataResult, started time.Time) *api.Response[*spec.VersionedPayloadAttestationData] {
if result.err != nil {
s.log.Debug().Err(result.err).Str("provider", result.provider).Msg("Failed to obtain payload attestation data")

return nil
}
if result.response == nil || result.response.Data == nil || result.response.Data.Version != spec.DataVersionGloas || result.response.Data.Gloas == nil || result.response.Data.Gloas.Slot != opts.Slot {
s.log.Debug().Str("provider", result.provider).Msg("Received invalid payload attestation data")

return nil
}
s.clientMonitor.StrategyOperation("first", result.provider, "payload attestation data", time.Since(started))

return &api.Response[*spec.VersionedPayloadAttestationData]{
Data: result.response.Data,
Metadata: make(map[string]any),
}
}
Loading
Loading