Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
00e43b0
Add Gloas self-build proposal support
AntiD2ta Aug 11, 2026
0bfd07c
Support custom beacon-node presets
AntiD2ta Aug 11, 2026
3865532
Sign the payload envelope before publishing the Gloas block
AntiD2ta Aug 12, 2026
181b455
Let every node receive the execution payload envelope
AntiD2ta Aug 12, 2026
d3442bf
Reject ePBS proposals with a zero fee recipient
AntiD2ta Aug 12, 2026
c207a4d
Preserve ePBS graffiti when the node client lookup fails
AntiD2ta Aug 12, 2026
249ac66
Honour the requested payload inclusion in the first strategy
AntiD2ta Aug 12, 2026
1a26339
Warn when Gloas proposals ignore builder settings
AntiD2ta Aug 12, 2026
80c0507
Validate the proposer index of a Gloas proposal
AntiD2ta Aug 12, 2026
1334b83
Warn when the beacon builder domain is unavailable
AntiD2ta Aug 12, 2026
50a0aed
Resolve the Gloas fork epoch when proposing
AntiD2ta Aug 12, 2026
3c81e95
Reuse Gloas body root for block signing
AntiD2ta Aug 12, 2026
326139b
Harden Gloas ePBS proposal handling
AntiD2ta Aug 13, 2026
cc08b1a
Update copyright years on files modified for Gloas support
AntiD2ta Aug 13, 2026
a02aff1
Bump go-eth2-client for the preset-aware ePBS body root fix
AntiD2ta Aug 13, 2026
bb790d6
Derive the Gloas block root from the retained body root
AntiD2ta Aug 13, 2026
afa7825
Handle Gloas cache blocks
AntiD2ta Aug 13, 2026
09d05ed
Decompose the Gloas ePBS proposal paths flagged for complexity
AntiD2ta Aug 13, 2026
13c9595
improve Gloas proposal telemetry
AntiD2ta Aug 13, 2026
e41404a
fix Gloas telemetry complexity
AntiD2ta Aug 13, 2026
bb5cb2a
Fix ctx should be the first parameter of a function
AntiD2ta Aug 14, 2026
ff5c0fd
fix vulnerable Go Docker builder
AntiD2ta Aug 14, 2026
8d73d72
fix: harden Gloas ePBS proposal handling
AntiD2ta Aug 14, 2026
577d61a
perf: cache hard fork schedule lookups
AntiD2ta Aug 14, 2026
6de010b
refactor: simplify fork schedule refresh
AntiD2ta Aug 14, 2026
96da104
fix: validate self-built Gloas proposals
AntiD2ta Aug 14, 2026
314e7d0
fix: satisfy Gloas proposal analysis
AntiD2ta Aug 14, 2026
a7d8a6b
fix: count completed Gloas proposal sources
AntiD2ta Aug 14, 2026
918f6a2
Add gloas changelog section
AntiD2ta Aug 14, 2026
e809c6e
Check the payload fee recipient before signing a Gloas envelope
AntiD2ta Aug 21, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
gloas:
- add gloas self-build beacon block proposal path: request an ePBS proposal with its payload, sign the beacon block and matching execution payload envelope, then publish the block followed by its envelope
- refuse a self-build gloas proposal whose execution payload pays no fee recipient, before its envelope is signed
- extend the first and best beaconblockproposal strategies for ePBS proposals, rejecting malformed or payload-excluded responses when payload inclusion was requested
- route the simple beaconblockproposal style through the first strategy; beacon node clients that do not support ePBS proposals now fail at startup
- skip the relay auction under gloas and warn operators that their relay settings are ignored
- add eth2client.custom-spec-support to enable dynamic SSZ encoding and decoding for beacon nodes using a non-mainnet preset
- cache the hard fork schedule in chaintime; known-fork lookups are allocation-free and unknown forks trigger a coalesced refresh
- do not update execution chain head state from gloas blocks, as an execution payload bid carries no execution block number
- report "builder" as a beacon block proposal source method
- update go-eth2-client to a gloas pseudo-version

1.13.1:
- initialise sync committee verification metrics to 0
- use dynssz v1.3.2 and corresponding lib updates
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS builder
FROM golang:1.26.6-bookworm AS builder

WORKDIR /app

Expand Down
3 changes: 2 additions & 1 deletion clients.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2020 - 2024 Attestant Limited.
// Copyright © 2020 - 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
Expand Down Expand Up @@ -50,6 +50,7 @@ func fetchClient(ctx context.Context, monitor metrics.Service, address string) (
httpclient.WithMonitor(monitor),
httpclient.WithTimeout(util.Timeout(fmt.Sprintf("eth2client.%s", address))),
httpclient.WithAddress(address),
httpclient.WithCustomSpecSupport(viper.GetBool("eth2client.custom-spec-support")),
httpclient.WithAllowDelayedStart(viper.GetBool("eth2client.allow-delayed-start")),
httpclient.WithExtraHeaders(map[string]string{
"User-Agent": fmt.Sprintf("Vouch/%s", ReleaseVersion),
Expand Down
145 changes: 145 additions & 0 deletions clients_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// 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 main

import (
"context"
"encoding/binary"
nethttp "net/http"
"net/http/httptest"
"testing"
"time"

bitfield "github.com/OffchainLabs/go-bitfield"
client "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/api"
apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas"
mockconsensusclient "github.com/attestantio/go-eth2-client/mock"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/altair"
"github.com/attestantio/go-eth2-client/spec/bellatrix"
"github.com/attestantio/go-eth2-client/spec/gloas"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/attestantio/vouch/services/metrics/null"
dynssz "github.com/pk910/dynamic-ssz"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)

func TestFetchClientCustomSpecSupport(t *testing.T) {
ctx := context.Background()
customSSZ := dynssz.NewDynSsz(map[string]any{
"SYNC_COMMITTEE_SIZE": uint64(32),
}, dynssz.WithNoFastSsz())
block := &gloas.BeaconBlock{
Slot: 1,
Body: &gloas.BeaconBlockBody{
SyncAggregate: &altair.SyncAggregate{
SyncCommitteeBits: bitfield.Bitvector512{0, 0, 0, 0},
},
},
}
body, err := customSSZ.MarshalSSZ(block.Body)
require.NoError(t, err)
require.EqualValues(t, 336, binary.LittleEndian.Uint32(body[200:204]))
encoded, err := customSSZ.MarshalSSZ(block)
require.NoError(t, err)

server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) {
switch r.URL.Path {
case "/eth/v1/node/version":
_, _ = w.Write([]byte(`{"data":{"version":"test"}}`))
case "/eth/v1/node/syncing":
_, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`))
case "/eth/v1/config/spec":
_, _ = w.Write([]byte(`{"data":{"SYNC_COMMITTEE_SIZE":"32"}}`))
case "/eth/v4/validator/blocks/1":
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Eth-Consensus-Version", "gloas")
w.Header().Set("Eth-Execution-Payload-Included", "false")
_, _ = w.Write(encoded)
default:
t.Errorf("unexpected request %s", r.URL.Path)
w.WriteHeader(nethttp.StatusNotFound)
}
}))
defer server.Close()

viper.Set("fetch-client-test-sentinel", "must not leak")
viper.Set("timeout", "2s")
viper.Set("eth2client.timeout", "2s")
viper.Set("eth2client.custom-spec-support", true)
t.Cleanup(func() {
viper.Reset()
require.Nil(t, viper.Get("fetch-client-test-sentinel"))
knownClientsMu.Lock()
delete(knownClients, server.URL)
knownClientsMu.Unlock()
})

service, err := fetchClient(ctx, null.New(), server.URL)
require.NoError(t, err)

includePayload := false
response, err := service.(client.EPBSProposalProvider).EPBSProposal(ctx, &api.EPBSProposalOpts{
Slot: 1,
IncludePayload: &includePayload,
})
require.NoError(t, err)
require.Equal(t, spec.DataVersionGloas, response.Data.Version)
require.Equal(t, block.Slot, response.Data.Gloas.Slot)
}

func TestSimpleProposalProviderRejectsZeroFeeRecipient(t *testing.T) {
ctx := context.Background()
const address = "http://proposal.test"
proposalClient, err := mockconsensusclient.New(ctx)
require.NoError(t, err)
proposalClient.EPBSProposalFunc = func(context.Context, *api.EPBSProposalOpts) (*api.Response[*api.VersionedEPBSProposal], error) {
return &api.Response[*api.VersionedEPBSProposal]{
Data: &api.VersionedEPBSProposal{
Version: spec.DataVersionGloas,
ExecutionPayloadIncluded: true,
GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{Body: &gloas.BeaconBlockBody{
SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{
FeeRecipient: bellatrix.ExecutionAddress{},
}},
}}},
},
}, nil
}
viper.Set("strategies.beaconblockproposal.style", "simple")
viper.Set("strategies.beaconblockproposal.beacon-node-addresses", []string{address})
viper.Set("strategies.beaconblockproposal.first.timeout", 10*time.Millisecond)
knownClientsMu.Lock()
knownClients[address] = proposalClient
knownClientsMu.Unlock()
t.Cleanup(func() {
viper.Reset()
knownClientsMu.Lock()
delete(knownClients, address)
delete(knownClients, "multi:"+address)
knownClientsMu.Unlock()
})

provider, err := selectProposalProvider(ctx, null.New(), nil, nil, nil)
require.NoError(t, err)
includePayload := true
response, err := provider.EPBSProposal(ctx, &api.EPBSProposalOpts{
Slot: phase0.Slot(1),
IncludePayload: &includePayload,
})
require.Nil(t, response)
require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout")
}
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ eth2client:
# operation, for example fetching the current list of active validators. These operations are not time-sensitive,
# and can contain large amounts of information, hence the longer timeout.
timeout: '2m'
# custom-spec-support enables dynamic SSZ encoding and decoding for beacon nodes that use a non-mainnet preset.
# It is slower than the generated mainnet path, so enable it only when the network requires it.
custom-spec-support: false
#
# allow-delayed-start allows Vouch to start if some of the consensus nodes are unavailable.
# Note that this can result in Vouch being active without being able to validate, however, if strategies use
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/attestantio/go-block-relay v0.6.0
github.com/attestantio/go-builder-client v0.8.0
github.com/attestantio/go-certmanager v0.2.0
github.com/attestantio/go-eth2-client v0.29.0
github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6
github.com/aws/aws-sdk-go v1.55.6
github.com/google/uuid v1.6.0
github.com/holiman/uint256 v1.3.2
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ github.com/attestantio/go-certmanager v0.2.0 h1:Hzj12L5fofK7b281uohMBN0HQuSx+8Rf
github.com/attestantio/go-certmanager v0.2.0/go.mod h1:Dn+C/okccD+2RugizT1ryrjX65cBMZl55fNYtmaVYAg=
github.com/attestantio/go-eth2-client v0.29.0 h1:nOVPR6boXuGn5yg94pVOKcaoiO9yyjaYbM1vzwPF4n4=
github.com/attestantio/go-eth2-client v0.29.0/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0=
github.com/attestantio/go-eth2-client v0.29.1-0.20260811144708-94db679b233c h1:fuiXnbUXUh6BbcV/ZkZHEuN39zLRqyMstKNg0fe6ZvQ=
github.com/attestantio/go-eth2-client v0.29.1-0.20260811144708-94db679b233c/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0=
github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6 h1:U59++PPwmKYGKg2Fnmvpf5EQCLW1oi/13GcRK+mU6A4=
github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0=
github.com/aws/aws-sdk-go v1.44.81/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
Expand Down
51 changes: 42 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2020 - 2025 Attestant Limited.
// Copyright © 2020 - 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
Expand Down Expand Up @@ -247,6 +247,7 @@ func fetchConfig() error {
viper.SetDefault("process-concurrency", int64(runtime.GOMAXPROCS(-1)))
viper.SetDefault("timeout", 2*time.Second)
viper.SetDefault("eth2client.timeout", 2*time.Minute)
viper.SetDefault("eth2client.custom-spec-support", false)
viper.SetDefault("eth2client.allow-delayed-start", true)
viper.SetDefault("controller.max-proposal-delay", 0)
viper.SetDefault("controller.max-attestation-delay", 4*time.Second)
Expand Down Expand Up @@ -670,7 +671,7 @@ func startProviders(ctx context.Context,
cache cache.Service,
) (
graffitiprovider.Service,
eth2client.ProposalProvider,
beaconblockproposer.ProposalDataProvider,
eth2client.AttestationDataProvider,
eth2client.AggregateAttestationProvider,
error,
Expand Down Expand Up @@ -814,8 +815,10 @@ func startSigningServices(ctx context.Context,
standardbeaconblockproposer.WithGraffitiProvider(graffitiProvider),
standardbeaconblockproposer.WithMonitor(monitor),
standardbeaconblockproposer.WithProposalSubmitter(submitterStrategy.(submitter.ProposalSubmitter)),
standardbeaconblockproposer.WithExecutionPayloadEnvelopeSubmitter(submitterStrategy.(submitter.ExecutionPayloadEnvelopeSubmitter)),
standardbeaconblockproposer.WithRANDAORevealSigner(signerSvc.(signer.RANDAORevealSigner)),
standardbeaconblockproposer.WithBeaconBlockSigner(signerSvc.(signer.BeaconBlockSigner)),
standardbeaconblockproposer.WithExecutionPayloadEnvelopeSigner(signerSvc.(signer.ExecutionPayloadEnvelopeSigner)),
standardbeaconblockproposer.WithBlobSidecarSigner(signerSvc.(signer.BlobSidecarSigner)),
standardbeaconblockproposer.WithUnblindFromAllRelays(viper.GetBool("beaconblockproposer.unblind-from-all-relays")),
standardbeaconblockproposer.WithBuilderBoostFactor(viper.GetUint64("beaconblockproposer.builder-boost-factor")),
Expand Down Expand Up @@ -1398,19 +1401,23 @@ func selectProposalProvider(ctx context.Context,
eth2Client eth2client.Service,
chainTime chaintime.Service,
cacheSvc cache.Service,
) (eth2client.ProposalProvider, error) {
var proposalProvider eth2client.ProposalProvider
) (beaconblockproposer.ProposalDataProvider, error) {
var proposalProvider beaconblockproposer.ProposalDataProvider
var err error
switch viper.GetString("strategies.beaconblockproposal.style") {
case "best":
log.Info().Msg("Starting best beacon block proposal strategy")
proposalProviders := make(map[string]eth2client.ProposalProvider)
proposalProviders := make(map[string]beaconblockproposer.ProposalDataProvider)
for _, address := range util.BeaconNodeAddresses("strategies.beaconblockproposal.best") {
client, err := fetchClient(ctx, monitor, address)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to fetch client %s for beacon block proposal strategy", address))
}
proposalProviders[address] = client.(eth2client.ProposalProvider)
provider, isProvider := client.(beaconblockproposer.ProposalDataProvider)
if !isProvider {
return nil, errors.New("beacon block proposal client does not support ePBS proposals")
}
proposalProviders[address] = provider
}
proposalProvider, err = bestbeaconblockproposalstrategy.New(ctx,
bestbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)),
Expand All @@ -1428,13 +1435,17 @@ func selectProposalProvider(ctx context.Context,
}
case "first":
log.Info().Msg("Starting first beacon block proposal strategy")
proposalProviders := make(map[string]eth2client.ProposalProvider)
proposalProviders := make(map[string]beaconblockproposer.ProposalDataProvider)
for _, address := range util.BeaconNodeAddresses("strategies.beaconblockproposal.first") {
client, err := fetchClient(ctx, monitor, address)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to fetch client %s for beacon block proposal strategy", address))
}
proposalProviders[address] = client.(eth2client.ProposalProvider)
provider, isProvider := client.(beaconblockproposer.ProposalDataProvider)
if !isProvider {
return nil, errors.New("beacon block proposal client does not support ePBS proposals")
}
proposalProviders[address] = provider
}
proposalProvider, err = firstbeaconblockproposalstrategy.New(ctx,
firstbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)),
Expand All @@ -1451,7 +1462,21 @@ func selectProposalProvider(ctx context.Context,
if err != nil {
return nil, errors.Wrap(err, "failed to fetch clients for simple beacon block proposal strategy")
}
proposalProvider = beaconBlockProposalClient.(eth2client.ProposalProvider)
provider, isProvider := beaconBlockProposalClient.(beaconblockproposer.ProposalDataProvider)
if !isProvider {
return nil, errors.New("beacon block proposal client does not support ePBS proposals")
}
proposalProvider, err = firstbeaconblockproposalstrategy.New(ctx,
firstbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)),
firstbeaconblockproposalstrategy.WithLogLevel(util.LogLevel("strategies.beaconblockproposal.first")),
firstbeaconblockproposalstrategy.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{
"simple": provider,
}),
firstbeaconblockproposalstrategy.WithTimeout(util.Timeout("strategies.beaconblockproposal.first")),
)
if err != nil {
return nil, errors.Wrap(err, "failed to start simple beacon block proposal strategy")
}
}

return proposalProvider, nil
Expand Down Expand Up @@ -1612,6 +1637,7 @@ func selectSubmitterStrategy(ctx context.Context, monitor metrics.Service, eth2C
immediatesubmitter.WithLogLevel(util.LogLevel("submitter.immediate")),
immediatesubmitter.WithClientMonitor(monitor.(metrics.ClientMonitor)),
immediatesubmitter.WithProposalSubmitter(eth2Client.(eth2client.ProposalSubmitter)),
immediatesubmitter.WithExecutionPayloadEnvelopeSubmitter(eth2Client.(eth2client.ExecutionPayloadEnvelopeSubmitter)),
immediatesubmitter.WithAttestationsSubmitter(eth2Client.(eth2client.AttestationsSubmitter)),
immediatesubmitter.WithSyncCommitteeMessagesSubmitter(eth2Client.(eth2client.SyncCommitteeMessagesSubmitter)),
immediatesubmitter.WithSyncCommitteeContributionsSubmitter(eth2Client.(eth2client.SyncCommitteeContributionsSubmitter)),
Expand Down Expand Up @@ -1669,6 +1695,12 @@ func startMultinodeSubmitter(ctx context.Context,
if err != nil {
return nil, err
}
executionPayloadEnvelopeSubmitters, err := genericAddressToClientMapper[eth2client.ExecutionPayloadEnvelopeSubmitter](ctx, monitor,
"submitter.proposal.multinode",
"execution payload envelope submitter strategy")
if err != nil {
return nil, err
}

beaconCommitteeSubscriptionsSubmitters, err := genericAddressToClientMapper[eth2client.BeaconCommitteeSubscriptionsSubmitter](ctx, monitor,
"submitter.beaconcommitteesubscription.multinode",
Expand Down Expand Up @@ -1711,6 +1743,7 @@ func startMultinodeSubmitter(ctx context.Context,
multinodesubmitter.WithLogLevel(util.LogLevel("submitter.multinode")),
multinodesubmitter.WithTimeout(util.Timeout("submitter.multinode")),
multinodesubmitter.WithProposalSubmitters(proposalSubmitters),
multinodesubmitter.WithExecutionPayloadEnvelopeSubmitters(executionPayloadEnvelopeSubmitters),
multinodesubmitter.WithAttestationsSubmitters(attestationsSubmitters),
multinodesubmitter.WithSyncCommitteeMessagesSubmitters(syncCommitteeMessagesSubmitters),
multinodesubmitter.WithSyncCommitteeContributionsSubmitters(syncCommitteeContributionsSubmitters),
Expand Down
Loading
Loading