From 00e43b04b9ed332bf4699117389edd455c7a896b Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 11 Aug 2026 18:40:26 +0200 Subject: [PATCH 01/30] Add Gloas self-build proposal support --- go.mod | 2 +- go.sum | 2 + main.go | 38 +- mock/eth2client.go | 41 +- services/beaconblockproposer/service.go | 7 + .../standard/gloas_test.go | 314 ++++++++++++++ .../standard/parameters.go | 48 ++- .../beaconblockproposer/standard/propose.go | 158 ++++++++ .../beaconblockproposer/standard/service.go | 60 +-- services/signer/mock/service.go | 13 + services/signer/service.go | 14 + services/signer/standard/service.go | 11 +- .../standard/signexecutionpayloadenvelope.go | 61 +++ ...nexecutionpayloadenvelope_internal_test.go | 122 ++++++ services/submitter/immediate/parameters.go | 8 + services/submitter/immediate/service.go | 2 + .../submitexecutionpayloadenvelope.go | 46 +++ .../submitexecutionpayloadenvelope_test.go | 59 +++ services/submitter/multinode/parameters.go | 8 + services/submitter/multinode/service.go | 2 + .../submitexecutionpayloadenvelope.go | 95 +++++ .../submitexecutionpayloadenvelope_test.go | 243 +++++++++++ services/submitter/service.go | 6 + .../best/beaconblockproposal.go | 192 +++++++++ .../best/beaconblockproposal_test.go | 14 +- .../best/epbsproposal_test.go | 383 ++++++++++++++++++ .../beaconblockproposal/best/parameters.go | 5 +- .../beaconblockproposal/best/service.go | 4 +- .../beaconblockproposal/best/service_test.go | 20 +- .../first/epbsproposal_test.go | 103 +++++ .../beaconblockproposal/first/parameters.go | 6 +- .../beaconblockproposal/first/service.go | 56 ++- 32 files changed, 2060 insertions(+), 83 deletions(-) create mode 100644 services/beaconblockproposer/standard/gloas_test.go create mode 100644 services/signer/standard/signexecutionpayloadenvelope.go create mode 100644 services/signer/standard/signexecutionpayloadenvelope_internal_test.go create mode 100644 services/submitter/immediate/submitexecutionpayloadenvelope.go create mode 100644 services/submitter/immediate/submitexecutionpayloadenvelope_test.go create mode 100644 services/submitter/multinode/submitexecutionpayloadenvelope.go create mode 100644 services/submitter/multinode/submitexecutionpayloadenvelope_test.go create mode 100644 strategies/beaconblockproposal/best/epbsproposal_test.go create mode 100644 strategies/beaconblockproposal/first/epbsproposal_test.go diff --git a/go.mod b/go.mod index e85ee9ba..77706d31 100644 --- a/go.mod +++ b/go.mod @@ -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.20260811144708-94db679b233c github.com/aws/aws-sdk-go v1.55.6 github.com/google/uuid v1.6.0 github.com/holiman/uint256 v1.3.2 diff --git a/go.sum b/go.sum index 8a92d48e..fca1f709 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ 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/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= diff --git a/main.go b/main.go index 6a24835a..8cb5a4aa 100644 --- a/main.go +++ b/main.go @@ -670,7 +670,7 @@ func startProviders(ctx context.Context, cache cache.Service, ) ( graffitiprovider.Service, - eth2client.ProposalProvider, + beaconblockproposer.ProposalDataProvider, eth2client.AttestationDataProvider, eth2client.AggregateAttestationProvider, error, @@ -814,8 +814,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")), @@ -1398,19 +1400,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)), @@ -1428,13 +1434,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)), @@ -1451,7 +1461,11 @@ 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) + var isProvider bool + proposalProvider, isProvider = beaconBlockProposalClient.(beaconblockproposer.ProposalDataProvider) + if !isProvider { + return nil, errors.New("beacon block proposal client does not support ePBS proposals") + } } return proposalProvider, nil @@ -1612,6 +1626,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)), @@ -1669,6 +1684,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", @@ -1711,6 +1732,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), diff --git a/mock/eth2client.go b/mock/eth2client.go index 958c8366..076166fe 100644 --- a/mock/eth2client.go +++ b/mock/eth2client.go @@ -31,6 +31,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/vouch/services/beaconblockproposer" ) // GenesisProvider is a mock for eth2client.GenesisProvider. @@ -578,7 +579,7 @@ func (m *SleepyBeaconCommitteeSubscriptionsSubmitter) SubmitBeaconCommitteeSubsc type ProposalProvider struct{} // NewProposalProvider returns a mock beacon block proposal provider. -func NewProposalProvider() eth2client.ProposalProvider { +func NewProposalProvider() *ProposalProvider { return &ProposalProvider{} } @@ -679,11 +680,21 @@ func (*ProposalProvider) Proposal(_ context.Context, }, nil } +// EPBSProposal is a mock. +func (*ProposalProvider) EPBSProposal(_ context.Context, + _ *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + return nil, errors.New("error") +} + // ErroringProposalProvider is a mock for eth2client.ProposalProvider. type ErroringProposalProvider struct{} // NewErroringProposalProvider returns a mock beacon block proposal provider. -func NewErroringProposalProvider() eth2client.ProposalProvider { +func NewErroringProposalProvider() *ErroringProposalProvider { return &ErroringProposalProvider{} } @@ -697,20 +708,41 @@ func (*ErroringProposalProvider) Proposal(_ context.Context, return nil, errors.New("error") } +// EPBSProposal is a mock. +func (*ErroringProposalProvider) EPBSProposal(_ context.Context, + _ *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + return nil, errors.New("error") +} + // SleepyProposalProvider is a mock for eth2client.ProposalProvider. type SleepyProposalProvider struct { wait time.Duration - next eth2client.ProposalProvider + next beaconblockproposer.ProposalDataProvider } // NewSleepyProposalProvider returns a mock beacon block proposal. -func NewSleepyProposalProvider(wait time.Duration, next eth2client.ProposalProvider) eth2client.ProposalProvider { +func NewSleepyProposalProvider(wait time.Duration, next beaconblockproposer.ProposalDataProvider) *SleepyProposalProvider { return &SleepyProposalProvider{ wait: wait, next: next, } } +// EPBSProposal is a mock. +func (m *SleepyProposalProvider) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + time.Sleep(m.wait) + return m.next.EPBSProposal(ctx, opts) +} + // Proposal is a mock. func (m *SleepyProposalProvider) Proposal(ctx context.Context, opts *api.ProposalOpts, @@ -1240,6 +1272,7 @@ func (*SpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map // Mainnet params (give or take). "DOMAIN_AGGREGATE_AND_PROOF": phase0.DomainType{0x06, 0x00, 0x00, 0x00}, "DOMAIN_BEACON_ATTESTER": phase0.DomainType{0x00, 0x00, 0x00, 0x00}, + "DOMAIN_BEACON_BUILDER": phase0.DomainType{0x0a, 0x00, 0x00, 0x00}, "DOMAIN_BEACON_PROPOSER": phase0.DomainType{0x01, 0x00, 0x00, 0x00}, "DOMAIN_CONTRIBUTION_AND_PROOF": phase0.DomainType{0x09, 0x00, 0x00, 0x00}, "DOMAIN_DEPOSIT": phase0.DomainType{0x03, 0x00, 0x00, 0x00}, diff --git a/services/beaconblockproposer/service.go b/services/beaconblockproposer/service.go index 8898e3f0..40e1824c 100644 --- a/services/beaconblockproposer/service.go +++ b/services/beaconblockproposer/service.go @@ -17,10 +17,17 @@ import ( "context" "fmt" + eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/spec/phase0" e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) +// ProposalDataProvider provides proposals across the legacy and ePBS forks. +type ProposalDataProvider interface { + eth2client.ProposalProvider + eth2client.EPBSProposalProvider +} + // Duty contains information about a beacon block proposal duty. type Duty struct { // Details for the duty. diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go new file mode 100644 index 00000000..b4be4c20 --- /dev/null +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -0,0 +1,314 @@ +// 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 standard_test + +import ( + "context" + "errors" + "testing" + "time" + + consensusapi "github.com/attestantio/go-eth2-client/api" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/deneb" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + mockaccountmanager "github.com/attestantio/vouch/services/accountmanager/mock" + "github.com/attestantio/vouch/services/beaconblockproposer" + "github.com/attestantio/vouch/services/beaconblockproposer/standard" + "github.com/attestantio/vouch/services/chaintime" + nullmetrics "github.com/attestantio/vouch/services/metrics/null" + "github.com/attestantio/vouch/services/signer" + mocksigner "github.com/attestantio/vouch/services/signer/mock" + "github.com/attestantio/vouch/services/submitter" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + e2types "github.com/wealdtech/go-eth2-types/v2" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" +) + +func TestProposeGloas(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + executionPayloadIncluded bool + envelopeRootMismatch bool + err string + }{ + { + name: "PayloadIncluded", + executionPayloadIncluded: true, + }, + { + name: "PayloadExcluded", + executionPayloadIncluded: false, + err: "failed to propose block: ePBS proposal excludes requested execution payload", + }, + { + name: "MismatchedEnvelopeRoot", + executionPayloadIncluded: true, + envelopeRootMismatch: true, + err: "failed to propose block: ePBS execution payload envelope is for incorrect block", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.ProposalFunc = func(context.Context, *consensusapi.ProposalOpts) (*consensusapi.Response[*consensusapi.VersionedProposal], error) { + return nil, errors.New("legacy proposal endpoint called") + } + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + var epbsOpts *consensusapi.EPBSProposalOpts + var responseProposal *consensusapi.VersionedEPBSProposal + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + epbsOpts = opts + responseOpts := *opts + responseOpts.IncludePayload = &test.executionPayloadIncluded + + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err == nil && response.Data.ExecutionPayloadIncluded { + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + if test.envelopeRootMismatch { + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot[0] ^= 0xff + } + } + if err == nil { + responseProposal = response.Data + } + return response, err + } + + proposalSubmitter := &capturingProposalSubmitter{} + signer := mocksigner.New() + signature := phase0.BLSSignature{0x01} + blockSigner := &capturingBeaconBlockSigner{signature: signature} + envelopeSignature := phase0.BLSSignature{0x03} + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + standard.WithBuilderBoostFactor(100), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 2) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + + err = service.Propose(ctx, duty) + require.NotNil(t, epbsOpts) + require.NotNil(t, epbsOpts.IncludePayload) + require.True(t, *epbsOpts.IncludePayload) + require.NotNil(t, epbsOpts.BuilderBoostFactor) + require.Equal(t, uint64(0), *epbsOpts.BuilderBoostFactor) + if test.err != "" { + require.EqualError(t, err, test.err) + require.Nil(t, proposalSubmitter.proposal) + if test.envelopeRootMismatch { + require.Zero(t, blockSigner.calls) + require.Zero(t, envelopeSigner.calls) + require.Nil(t, envelopeSubmitter.opts) + } + } else { + require.NoError(t, err) + require.NotNil(t, proposalSubmitter.proposal) + require.NotNil(t, proposalSubmitter.proposal.Gloas) + require.Equal(t, signature, proposalSubmitter.proposal.Gloas.Signature) + require.Same(t, responseProposal.GloasContents.Block, proposalSubmitter.proposal.Gloas.Message) + require.Same(t, responseProposal.GloasContents.ExecutionPayloadEnvelope, envelopeSigner.envelope) + require.NotNil(t, envelopeSubmitter.opts) + require.Equal(t, envelopeSignature, envelopeSubmitter.opts.SignedExecutionPayloadEnvelope.Gloas.Signature) + require.Same(t, responseProposal.GloasContents.ExecutionPayloadEnvelope, envelopeSubmitter.opts.SignedExecutionPayloadEnvelope.Gloas.Message) + require.NotEmpty(t, responseProposal.GloasContents.KZGProofs) + require.NotEmpty(t, responseProposal.GloasContents.Blobs) + require.Equal(t, responseProposal.GloasContents.KZGProofs, envelopeSubmitter.opts.KZGProofs) + require.Equal(t, responseProposal.GloasContents.Blobs, envelopeSubmitter.opts.Blobs) + } + }) + } +} + +func TestProposePreGloas(t *testing.T) { + ctx := context.Background() + + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.ProposalFunc = responseClient.Proposal + proposalClient.EPBSProposalFunc = func(context.Context, *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + return nil, errors.New("ePBS proposal endpoint called") + } + + proposalSubmitter := &capturingProposalSubmitter{} + signer := mocksigner.New() + blockSigner := &capturingBeaconBlockSigner{signature: phase0.BLSSignature{0x01}} + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{gloasForkEpoch: 1}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 2) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + + require.NoError(t, service.Propose(ctx, duty)) + require.NotNil(t, proposalSubmitter.proposal) + require.Nil(t, proposalSubmitter.proposal.Gloas) +} + +type capturingProposalSubmitter struct { + proposal *consensusapi.VersionedSignedProposal +} + +func (s *capturingProposalSubmitter) SubmitProposal(_ context.Context, proposal *consensusapi.VersionedSignedProposal) error { + s.proposal = proposal + + return nil +} + +var _ submitter.ProposalSubmitter = (*capturingProposalSubmitter)(nil) + +type capturingBeaconBlockSigner struct { + signature phase0.BLSSignature + calls int +} + +type capturingExecutionPayloadEnvelopeSigner struct { + signature phase0.BLSSignature + envelope *gloas.ExecutionPayloadEnvelope + calls int +} + +func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( + _ context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + envelope *gloas.ExecutionPayloadEnvelope, +) (phase0.BLSSignature, error) { + s.calls++ + s.envelope = envelope + return s.signature, nil +} + +var _ signer.ExecutionPayloadEnvelopeSigner = (*capturingExecutionPayloadEnvelopeSigner)(nil) + +type capturingExecutionPayloadEnvelopeSubmitter struct { + opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts +} + +func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope( + _ context.Context, + opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts, +) error { + s.opts = opts + return nil +} + +var _ submitter.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) + +func (s *capturingBeaconBlockSigner) SignBeaconBlockProposal( + _ context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + _ phase0.ValidatorIndex, + _ phase0.Root, + _ phase0.Root, + _ phase0.Root, +) (phase0.BLSSignature, error) { + s.calls++ + return s.signature, nil +} + +type forkChainTime struct { + gloasForkEpoch phase0.Epoch +} + +func (*forkChainTime) GenesisTime() time.Time { + return time.Time{} +} + +func (*forkChainTime) StartOfSlot(phase0.Slot) time.Time { + return time.Time{} +} + +func (*forkChainTime) StartOfEpoch(phase0.Epoch) time.Time { + return time.Time{} +} + +func (*forkChainTime) CurrentSlot() phase0.Slot { + return 0 +} + +func (*forkChainTime) CurrentEpoch() phase0.Epoch { + return 0 +} + +func (*forkChainTime) SlotToEpoch(phase0.Slot) phase0.Epoch { + return 0 +} + +func (*forkChainTime) FirstSlotOfEpoch(phase0.Epoch) phase0.Slot { + return 0 +} + +func (s *forkChainTime) HardForkEpoch(context.Context, string) phase0.Epoch { + return s.gloasForkEpoch +} + +var _ chaintime.Service = (*forkChainTime)(nil) + +type testAccount struct{} + +func (*testAccount) ID() uuid.UUID { + return uuid.Nil +} + +func (*testAccount) Name() string { + return "test" +} + +func (*testAccount) PublicKey() e2types.PublicKey { + return nil +} + +var _ e2wtypes.Account = (*testAccount)(nil) diff --git a/services/beaconblockproposer/standard/parameters.go b/services/beaconblockproposer/standard/parameters.go index 633999ce..9a389ccf 100644 --- a/services/beaconblockproposer/standard/parameters.go +++ b/services/beaconblockproposer/standard/parameters.go @@ -17,8 +17,8 @@ import ( "errors" "github.com/attestantio/go-block-relay/services/blockauctioneer" - eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/vouch/services/accountmanager" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/cache" "github.com/attestantio/vouch/services/chaintime" "github.com/attestantio/vouch/services/graffitiprovider" @@ -29,20 +29,22 @@ import ( ) type parameters struct { - logLevel zerolog.Level - monitor metrics.Service - chainTime chaintime.Service - blockAuctioneer blockauctioneer.BlockAuctioneer - proposalProvider eth2client.ProposalProvider - validatingAccountsProvider accountmanager.ValidatingAccountsProvider - executionChainHeadProvider cache.ExecutionChainHeadProvider - graffitiProvider graffitiprovider.Service - proposalSubmitter submitter.ProposalSubmitter - randaoRevealSigner signer.RANDAORevealSigner - beaconBlockSigner signer.BeaconBlockSigner - blobSidecarSigner signer.BlobSidecarSigner - unblindFromAllRelays bool - builderBoostFactor uint64 + monitor metrics.Service + proposalProvider beaconblockproposer.ProposalDataProvider + validatingAccountsProvider accountmanager.ValidatingAccountsProvider + executionChainHeadProvider cache.ExecutionChainHeadProvider + graffitiProvider graffitiprovider.Service + proposalSubmitter submitter.ProposalSubmitter + executionPayloadEnvelopeSubmitter submitter.ExecutionPayloadEnvelopeSubmitter + randaoRevealSigner signer.RANDAORevealSigner + beaconBlockSigner signer.BeaconBlockSigner + executionPayloadEnvelopeSigner signer.ExecutionPayloadEnvelopeSigner + blobSidecarSigner signer.BlobSidecarSigner + chainTime chaintime.Service + blockAuctioneer blockauctioneer.BlockAuctioneer + logLevel zerolog.Level + unblindFromAllRelays bool + builderBoostFactor uint64 } // Parameter is the interface for service parameters. @@ -78,7 +80,7 @@ func WithBlockAuctioneer(auctioneer blockauctioneer.BlockAuctioneer) Parameter { } // WithProposalDataProvider sets the proposal data provider. -func WithProposalDataProvider(provider eth2client.ProposalProvider) Parameter { +func WithProposalDataProvider(provider beaconblockproposer.ProposalDataProvider) Parameter { return parameterFunc(func(p *parameters) { p.proposalProvider = provider }) @@ -119,6 +121,13 @@ func WithProposalSubmitter(submitter submitter.ProposalSubmitter) Parameter { }) } +// WithExecutionPayloadEnvelopeSubmitter sets the execution payload envelope submitter. +func WithExecutionPayloadEnvelopeSubmitter(submitter submitter.ExecutionPayloadEnvelopeSubmitter) Parameter { + return parameterFunc(func(p *parameters) { + p.executionPayloadEnvelopeSubmitter = submitter + }) +} + // WithRANDAORevealSigner sets the RANDAO reveal signer. func WithRANDAORevealSigner(signer signer.RANDAORevealSigner) Parameter { return parameterFunc(func(p *parameters) { @@ -133,6 +142,13 @@ func WithBeaconBlockSigner(signer signer.BeaconBlockSigner) Parameter { }) } +// WithExecutionPayloadEnvelopeSigner sets the execution payload envelope signer. +func WithExecutionPayloadEnvelopeSigner(signer signer.ExecutionPayloadEnvelopeSigner) Parameter { + return parameterFunc(func(p *parameters) { + p.executionPayloadEnvelopeSigner = signer + }) +} + // WithBlobSidecarSigner sets the blob sidecar signer. func WithBlobSidecarSigner(signer signer.BlobSidecarSigner) Parameter { return parameterFunc(func(p *parameters) { diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index ac2a8263..43a8e31e 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -37,6 +37,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/electra" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/util" @@ -140,6 +141,10 @@ func (s *Service) proposeBlock(ctx context.Context, duty *beaconblockproposer.Duty, graffiti [32]byte, ) error { + if s.chainTime.SlotToEpoch(duty.Slot()) >= s.gloasForkEpoch { + return s.proposeEPBSBlock(ctx, duty, graffiti) + } + var auctionResults *blockauctioneer.Results var err error if s.blockAuctioneer != nil { @@ -210,6 +215,91 @@ func (s *Service) proposeBlock(ctx context.Context, return nil } +// proposeEPBSBlock proposes a Gloas block. +func (s *Service) proposeEPBSBlock(ctx context.Context, + duty *beaconblockproposer.Duty, + graffiti [32]byte, +) error { + if s.executionPayloadEnvelopeSigner == nil { + return errors.New("no execution payload envelope signer available") + } + if s.executionPayloadEnvelopeSubmitter == nil { + return errors.New("no execution payload envelope submitter available") + } + + includePayload := true + // Force local building and include its payload so any beacon node can publish it. + selfBuildBoostFactor := uint64(0) + proposalResponse, err := s.proposalProvider.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: duty.Slot(), + RandaoReveal: duty.RANDAOReveal(), + Graffiti: graffiti, + IncludePayload: &includePayload, + BuilderBoostFactor: &selfBuildBoostFactor, + }) + if err != nil { + return errors.Wrap(err, "failed to obtain ePBS proposal") + } + proposal := proposalResponse.Data + monitorBeaconBlockProposalSource("local") + if !proposal.ExecutionPayloadIncluded { + return errors.New("ePBS proposal excludes requested execution payload") + } + + if err := s.confirmEPBSProposalData(ctx, proposal, duty); err != nil { + return err + } + + envelope, err := proposal.ExecutionPayloadEnvelope() + if err != nil { + return errors.Wrap(err, "failed to obtain execution payload envelope") + } + blockRoot, err := proposal.GloasContents.Block.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "failed to calculate hash tree root of ePBS block") + } + if blockRoot != envelope.BeaconBlockRoot { + return errors.New("ePBS execution payload envelope is for incorrect block") + } + + signedProposal, err := s.signEPBSProposalData(ctx, proposal, duty) + if err != nil { + return err + } + + if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { + return errors.Wrap(err, "failed to submit proposal") + } + + signature, err := s.executionPayloadEnvelopeSigner.SignExecutionPayloadEnvelope(ctx, duty.Account(), duty.Slot(), envelope) + if err != nil { + return errors.Wrap(err, "failed to sign execution payload envelope") + } + kzgProofs, err := proposal.KZGProofs() + if err != nil { + return errors.Wrap(err, "failed to obtain execution payload envelope KZG proofs") + } + blobs, err := proposal.Blobs() + if err != nil { + return errors.Wrap(err, "failed to obtain execution payload envelope blobs") + } + if err := s.executionPayloadEnvelopeSubmitter.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{ + SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: envelope, + Signature: signature, + }, + }, + KZGProofs: kzgProofs, + Blobs: blobs, + }); err != nil { + return errors.Wrap(err, "failed to submit execution payload envelope") + } + + return nil +} + func (*Service) confirmProposalData(_ context.Context, proposal *api.VersionedProposal, duty *beaconblockproposer.Duty, @@ -230,6 +320,21 @@ func (*Service) confirmProposalData(_ context.Context, return nil } +func (*Service) confirmEPBSProposalData(_ context.Context, + proposal *api.VersionedEPBSProposal, + duty *beaconblockproposer.Duty, +) error { + proposalSlot, err := proposal.Slot() + if err != nil { + return errors.Wrap(err, "failed to obtain ePBS proposal slot") + } + if proposalSlot != duty.Slot() { + return errors.New("ePBS proposal data for incorrect slot") + } + + return nil +} + // skipcq: GO-R1005 // Complexity is due to handling all Ethereum protocol versions. // Each version requires specific signing logic for blinded/unblinded proposals. @@ -363,6 +468,59 @@ func (s *Service) signProposalData(ctx context.Context, return signedProposal, nil } +func (s *Service) signEPBSProposalData(ctx context.Context, + proposal *api.VersionedEPBSProposal, + duty *beaconblockproposer.Duty, +) ( + *api.VersionedSignedProposal, + error, +) { + if proposal.Version != spec.DataVersionGloas { + return nil, errors.New("unhandled ePBS proposal version") + } + + bodyRoot, err := proposal.BodyRoot() + if err != nil { + return nil, errors.Wrap(err, "failed to calculate hash tree root of ePBS block body proposal") + } + + parentRoot, err := proposal.ParentRoot() + if err != nil { + return nil, errors.Wrap(err, "failed to obtain parent root of ePBS block proposal") + } + + stateRoot, err := proposal.StateRoot() + if err != nil { + return nil, errors.Wrap(err, "failed to obtain state root of ePBS block proposal") + } + + sig, err := s.beaconBlockSigner.SignBeaconBlockProposal(ctx, + duty.Account(), + duty.Slot(), + duty.ValidatorIndex(), + parentRoot, + stateRoot, + bodyRoot) + if err != nil { + return nil, errors.Wrap(err, "failed to sign ePBS beacon block proposal") + } + + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded { + block = proposal.GloasContents.Block + } + + return &api.VersionedSignedProposal{ + Version: proposal.Version, + ExecutionValue: proposal.ExecutionValue, + ConsensusValue: proposal.ConsensusValue, + Gloas: &gloas.SignedBeaconBlock{ + Message: block, + Signature: sig, + }, + }, nil +} + func (s *Service) auctionBlock(ctx context.Context, duty *beaconblockproposer.Duty, ) ( diff --git a/services/beaconblockproposer/standard/service.go b/services/beaconblockproposer/standard/service.go index 959fbd6a..27b03d60 100644 --- a/services/beaconblockproposer/standard/service.go +++ b/services/beaconblockproposer/standard/service.go @@ -19,7 +19,6 @@ import ( "time" "github.com/attestantio/go-block-relay/services/blockauctioneer" - eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/services/accountmanager" "github.com/attestantio/vouch/services/beaconblockproposer" @@ -38,19 +37,22 @@ import ( // Service is a beacon block proposer. type Service struct { - log zerolog.Logger - chainTime chaintime.Service - blockAuctioneer blockauctioneer.BlockAuctioneer - proposalProvider eth2client.ProposalProvider - validatingAccountsProvider accountmanager.ValidatingAccountsProvider - executionChainHeadProvider cache.ExecutionChainHeadProvider - graffitiProvider graffitiprovider.Service - proposalSubmitter submitter.ProposalSubmitter - randaoRevealSigner signer.RANDAORevealSigner - beaconBlockSigner signer.BeaconBlockSigner - blobSidecarSigner signer.BlobSidecarSigner - unblindFromAllRelays bool - builderBoostFactor uint64 + log zerolog.Logger + proposalProvider beaconblockproposer.ProposalDataProvider + validatingAccountsProvider accountmanager.ValidatingAccountsProvider + executionChainHeadProvider cache.ExecutionChainHeadProvider + graffitiProvider graffitiprovider.Service + proposalSubmitter submitter.ProposalSubmitter + executionPayloadEnvelopeSubmitter submitter.ExecutionPayloadEnvelopeSubmitter + randaoRevealSigner signer.RANDAORevealSigner + beaconBlockSigner signer.BeaconBlockSigner + executionPayloadEnvelopeSigner signer.ExecutionPayloadEnvelopeSigner + blobSidecarSigner signer.BlobSidecarSigner + chainTime chaintime.Service + blockAuctioneer blockauctioneer.BlockAuctioneer + unblindFromAllRelays bool + builderBoostFactor uint64 + gloasForkEpoch phase0.Epoch } // New creates a new beacon block proposer. @@ -70,20 +72,24 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { return nil, errors.New("failed to register metrics") } + gloasForkEpoch := parameters.chainTime.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") s := &Service{ - log: log, - chainTime: parameters.chainTime, - blockAuctioneer: parameters.blockAuctioneer, - proposalProvider: parameters.proposalProvider, - validatingAccountsProvider: parameters.validatingAccountsProvider, - executionChainHeadProvider: parameters.executionChainHeadProvider, - graffitiProvider: parameters.graffitiProvider, - proposalSubmitter: parameters.proposalSubmitter, - randaoRevealSigner: parameters.randaoRevealSigner, - beaconBlockSigner: parameters.beaconBlockSigner, - blobSidecarSigner: parameters.blobSidecarSigner, - unblindFromAllRelays: parameters.unblindFromAllRelays, - builderBoostFactor: parameters.builderBoostFactor, + log: log, + chainTime: parameters.chainTime, + blockAuctioneer: parameters.blockAuctioneer, + proposalProvider: parameters.proposalProvider, + validatingAccountsProvider: parameters.validatingAccountsProvider, + executionChainHeadProvider: parameters.executionChainHeadProvider, + graffitiProvider: parameters.graffitiProvider, + proposalSubmitter: parameters.proposalSubmitter, + executionPayloadEnvelopeSubmitter: parameters.executionPayloadEnvelopeSubmitter, + randaoRevealSigner: parameters.randaoRevealSigner, + beaconBlockSigner: parameters.beaconBlockSigner, + executionPayloadEnvelopeSigner: parameters.executionPayloadEnvelopeSigner, + blobSidecarSigner: parameters.blobSidecarSigner, + unblindFromAllRelays: parameters.unblindFromAllRelays, + builderBoostFactor: parameters.builderBoostFactor, + gloasForkEpoch: gloasForkEpoch, } return s, nil diff --git a/services/signer/mock/service.go b/services/signer/mock/service.go index 8f9f8625..eccd8921 100644 --- a/services/signer/mock/service.go +++ b/services/signer/mock/service.go @@ -18,6 +18,7 @@ import ( "github.com/attestantio/go-builder-client/api" "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) @@ -181,3 +182,15 @@ func (*Service) SignBlobSidecar(_ context.Context, ) { return phase0.BLSSignature{}, nil } + +// SignExecutionPayloadEnvelope signs an execution payload envelope. +func (*Service) SignExecutionPayloadEnvelope(_ context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + _ *gloas.ExecutionPayloadEnvelope, +) ( + phase0.BLSSignature, + error, +) { + return phase0.BLSSignature{}, nil +} diff --git a/services/signer/service.go b/services/signer/service.go index 5bc4cef7..fde44dab 100644 --- a/services/signer/service.go +++ b/services/signer/service.go @@ -19,6 +19,7 @@ import ( "github.com/attestantio/go-builder-client/api" "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) @@ -104,6 +105,19 @@ type BlobSidecarSigner interface { ) } +// ExecutionPayloadEnvelopeSigner provides methods to sign execution payload envelopes. +type ExecutionPayloadEnvelopeSigner interface { + // SignExecutionPayloadEnvelope signs an execution payload envelope. + SignExecutionPayloadEnvelope(ctx context.Context, + account e2wtypes.Account, + slot phase0.Slot, + envelope *gloas.ExecutionPayloadEnvelope, + ) ( + phase0.BLSSignature, + error, + ) +} + // RANDAORevealSigner provides methods to sign RANDAO reveals. type RANDAORevealSigner interface { // SignRANDAOReveal returns a RANDAO signature. diff --git a/services/signer/standard/service.go b/services/signer/standard/service.go index fa8d4d10..844ef674 100644 --- a/services/signer/standard/service.go +++ b/services/signer/standard/service.go @@ -29,6 +29,7 @@ import ( // Service is the manager for signers. type Service struct { monitor metrics.SignerMonitor + domainProvider eth2client.DomainProvider clientMonitor metrics.ClientMonitor slotsPerEpoch phase0.Slot beaconProposerDomainType phase0.DomainType @@ -41,7 +42,7 @@ type Service struct { contributionAndProofDomainType *phase0.DomainType applicationBuilderDomainType *phase0.DomainType blobSidecarDomainType *phase0.DomainType - domainProvider eth2client.DomainProvider + beaconBuilderDomainType *phase0.DomainType } // module-wide log. @@ -126,9 +127,15 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { blobSidecarDomainType = &tmp } + var beaconBuilderDomainType *phase0.DomainType + if tmp, err := domainType(spec, "DOMAIN_BEACON_BUILDER"); err == nil { + beaconBuilderDomainType = &tmp + } + s := &Service{ monitor: parameters.monitor, clientMonitor: parameters.clientMonitor, + domainProvider: parameters.domainProvider, slotsPerEpoch: phase0.Slot(slotsPerEpoch), beaconAttesterDomainType: beaconAttesterDomainType, beaconProposerDomainType: beaconProposerDomainType, @@ -140,7 +147,7 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { contributionAndProofDomainType: contributionAndProofDomainType, applicationBuilderDomainType: applicationBuilderDomainType, blobSidecarDomainType: blobSidecarDomainType, - domainProvider: parameters.domainProvider, + beaconBuilderDomainType: beaconBuilderDomainType, } return s, nil diff --git a/services/signer/standard/signexecutionpayloadenvelope.go b/services/signer/standard/signexecutionpayloadenvelope.go new file mode 100644 index 00000000..399e9476 --- /dev/null +++ b/services/signer/standard/signexecutionpayloadenvelope.go @@ -0,0 +1,61 @@ +// 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 standard + +import ( + "context" + + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" + "go.opentelemetry.io/otel" +) + +// SignExecutionPayloadEnvelope signs an execution payload envelope. +func (s *Service) SignExecutionPayloadEnvelope(ctx context.Context, + account e2wtypes.Account, + slot phase0.Slot, + envelope *gloas.ExecutionPayloadEnvelope, +) ( + phase0.BLSSignature, + error, +) { + ctx, span := otel.Tracer("attestantio.vouch.services.signer.standard").Start(ctx, "SignExecutionPayloadEnvelope") + defer span.End() + + if envelope == nil { + return phase0.BLSSignature{}, errors.New("no execution payload envelope supplied") + } + if s.beaconBuilderDomainType == nil { + return phase0.BLSSignature{}, errors.New("no beacon builder domain type available; cannot sign") + } + + root, err := envelope.HashTreeRoot() + if err != nil { + return phase0.BLSSignature{}, errors.Wrap(err, "failed to calculate execution payload envelope hash tree root") + } + domain, err := s.domainProvider.Domain(ctx, + *s.beaconBuilderDomainType, + phase0.Epoch(slot/s.slotsPerEpoch)) + if err != nil { + return phase0.BLSSignature{}, errors.Wrap(err, "failed to obtain signature domain for execution payload envelope") + } + signature, err := s.sign(ctx, account, root, domain) + if err != nil { + return phase0.BLSSignature{}, errors.Wrap(err, "failed to sign execution payload envelope") + } + + return signature, nil +} diff --git a/services/signer/standard/signexecutionpayloadenvelope_internal_test.go b/services/signer/standard/signexecutionpayloadenvelope_internal_test.go new file mode 100644 index 00000000..1b4e2228 --- /dev/null +++ b/services/signer/standard/signexecutionpayloadenvelope_internal_test.go @@ -0,0 +1,122 @@ +// 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 standard + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/api" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/vouch/mock" + nullmetrics "github.com/attestantio/vouch/services/metrics/null" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestSignExecutionPayloadEnvelope(t *testing.T) { + ctx := context.Background() + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + includePayload := true + proposalResponse, err := proposalClient.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 33, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + + domainProvider := &recordingDomainProvider{} + service, err := New(ctx, + WithLogLevel(zerolog.Disabled), + WithMonitor(nullmetrics.New()), + WithClientMonitor(nullmetrics.New()), + WithSpecProvider(mock.NewSpecProvider()), + WithDomainProvider(domainProvider), + ) + require.NoError(t, err) + account := newMockAccount("builder") + + signature, err := service.SignExecutionPayloadEnvelope(ctx, + account, + 33, + proposalResponse.Data.GloasContents.ExecutionPayloadEnvelope, + ) + require.NoError(t, err) + require.NotEqual(t, phase0.BLSSignature{}, signature) + require.Equal(t, 1, account.signCount) + require.Equal(t, phase0.DomainType{0x0a}, domainProvider.domainType) + require.Equal(t, phase0.Epoch(1), domainProvider.epoch) +} + +func TestSignExecutionPayloadEnvelopeUnavailableBeforeGloas(t *testing.T) { + ctx := context.Background() + service, err := New(ctx, + WithLogLevel(zerolog.Disabled), + WithMonitor(nullmetrics.New()), + WithClientMonitor(nullmetrics.New()), + WithSpecProvider(&preGloasSpecProvider{}), + WithDomainProvider(&recordingDomainProvider{}), + ) + require.NoError(t, err) + account := newMockAccount("legacy") + + signature, err := service.SignBeaconBlockProposal(ctx, + account, + 32, + 0, + phase0.Root{}, + phase0.Root{}, + phase0.Root{}, + ) + require.NoError(t, err) + require.NotEqual(t, phase0.BLSSignature{}, signature) + + _, err = service.SignExecutionPayloadEnvelope(ctx, account, 32, &gloas.ExecutionPayloadEnvelope{}) + require.EqualError(t, err, "no beacon builder domain type available; cannot sign") + require.Equal(t, 1, account.signCount) +} + +type preGloasSpecProvider struct{} + +func (*preGloasSpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map[string]any], error) { + return &api.Response[map[string]any]{ + Data: map[string]any{ + "SLOTS_PER_EPOCH": uint64(32), + "DOMAIN_BEACON_ATTESTER": phase0.DomainType{0x01}, + "DOMAIN_BEACON_PROPOSER": phase0.DomainType{0x02}, + "DOMAIN_RANDAO": phase0.DomainType{0x03}, + "DOMAIN_SELECTION_PROOF": phase0.DomainType{0x04}, + "DOMAIN_AGGREGATE_AND_PROOF": phase0.DomainType{0x05}, + }, + }, nil +} + +type recordingDomainProvider struct { + domainType phase0.DomainType + epoch phase0.Epoch +} + +func (p *recordingDomainProvider) Domain(_ context.Context, domainType phase0.DomainType, epoch phase0.Epoch) (phase0.Domain, error) { + p.domainType = domainType + p.epoch = epoch + var domain phase0.Domain + copy(domain[:], domainType[:]) + return domain, nil +} + +func (*recordingDomainProvider) GenesisDomain(context.Context, phase0.DomainType) (phase0.Domain, error) { + return phase0.Domain{}, nil +} diff --git a/services/submitter/immediate/parameters.go b/services/submitter/immediate/parameters.go index 51781898..43f228e3 100644 --- a/services/submitter/immediate/parameters.go +++ b/services/submitter/immediate/parameters.go @@ -26,6 +26,7 @@ type parameters struct { logLevel zerolog.Level clientMonitor metrics.ClientMonitor proposalSubmitter eth2client.ProposalSubmitter + executionPayloadEnvelopeSubmitter eth2client.ExecutionPayloadEnvelopeSubmitter attestationsSubmitter eth2client.AttestationsSubmitter beaconCommitteeSubscriptionsSubmitter eth2client.BeaconCommitteeSubscriptionsSubmitter aggregateAttestationsSubmitter eth2client.AggregateAttestationsSubmitter @@ -67,6 +68,13 @@ func WithProposalSubmitter(submitter eth2client.ProposalSubmitter) Parameter { }) } +// WithExecutionPayloadEnvelopeSubmitter sets the execution payload envelope submitter. +func WithExecutionPayloadEnvelopeSubmitter(submitter eth2client.ExecutionPayloadEnvelopeSubmitter) Parameter { + return parameterFunc(func(p *parameters) { + p.executionPayloadEnvelopeSubmitter = submitter + }) +} + // WithAttestationsSubmitter sets the attestation submitter. func WithAttestationsSubmitter(submitter eth2client.AttestationsSubmitter) Parameter { return parameterFunc(func(p *parameters) { diff --git a/services/submitter/immediate/service.go b/services/submitter/immediate/service.go index 47f2ce5e..4f3fa989 100644 --- a/services/submitter/immediate/service.go +++ b/services/submitter/immediate/service.go @@ -35,6 +35,7 @@ type Service struct { clientMonitor metrics.ClientMonitor attestationsSubmitter eth2client.AttestationsSubmitter proposalSubmitter eth2client.ProposalSubmitter + executionPayloadEnvelopeSubmitter eth2client.ExecutionPayloadEnvelopeSubmitter beaconCommitteeSubscriptionsSubmitter eth2client.BeaconCommitteeSubscriptionsSubmitter aggregateAttestationsSubmitter eth2client.AggregateAttestationsSubmitter proposalPreparationsSubmitter eth2client.ProposalPreparationsSubmitter @@ -61,6 +62,7 @@ func New(_ context.Context, params ...Parameter) (*Service, error) { clientMonitor: parameters.clientMonitor, attestationsSubmitter: parameters.attestationsSubmitter, proposalSubmitter: parameters.proposalSubmitter, + executionPayloadEnvelopeSubmitter: parameters.executionPayloadEnvelopeSubmitter, beaconCommitteeSubscriptionsSubmitter: parameters.beaconCommitteeSubscriptionsSubmitter, aggregateAttestationsSubmitter: parameters.aggregateAttestationsSubmitter, proposalPreparationsSubmitter: parameters.proposalPreparationsSubmitter, diff --git a/services/submitter/immediate/submitexecutionpayloadenvelope.go b/services/submitter/immediate/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..cce7080e --- /dev/null +++ b/services/submitter/immediate/submitexecutionpayloadenvelope.go @@ -0,0 +1,46 @@ +// 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 immediate + +import ( + "context" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/pkg/errors" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + if opts == nil { + return errors.New("no execution payload envelope supplied") + } + if s.executionPayloadEnvelopeSubmitter == nil { + return errors.New("no execution payload envelope submitter configured") + } + + started := time.Now() + err := s.executionPayloadEnvelopeSubmitter.SubmitExecutionPayloadEnvelope(ctx, opts) + if service, isService := s.executionPayloadEnvelopeSubmitter.(eth2client.Service); isService { + s.clientMonitor.ClientOperation(service.Address(), "submit execution payload envelope", err == nil, time.Since(started)) + } else { + s.clientMonitor.ClientOperation("", "submit execution payload envelope", err == nil, time.Since(started)) + } + if err != nil { + return errors.Wrap(err, "failed to submit execution payload envelope") + } + + return nil +} diff --git a/services/submitter/immediate/submitexecutionpayloadenvelope_test.go b/services/submitter/immediate/submitexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..09f4e9b7 --- /dev/null +++ b/services/submitter/immediate/submitexecutionpayloadenvelope_test.go @@ -0,0 +1,59 @@ +// 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 immediate_test + +import ( + "context" + "testing" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/vouch/mock" + "github.com/attestantio/vouch/services/submitter/immediate" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadEnvelope(t *testing.T) { + ctx := context.Background() + capture := &capturingExecutionPayloadEnvelopeSubmitter{} + service, err := immediate.New(ctx, + immediate.WithLogLevel(zerolog.Disabled), + immediate.WithProposalSubmitter(mock.NewProposalSubmitter()), + immediate.WithExecutionPayloadEnvelopeSubmitter(capture), + immediate.WithAttestationsSubmitter(mock.NewAttestationsSubmitter()), + immediate.WithBeaconCommitteeSubscriptionsSubmitter(mock.NewBeaconCommitteeSubscriptionsSubmitter()), + immediate.WithAggregateAttestationsSubmitter(mock.NewAggregateAttestationsSubmitter()), + immediate.WithProposalPreparationsSubmitter(mock.NewProposalPreparationsSubmitter()), + immediate.WithSyncCommitteeMessagesSubmitter(mock.NewSyncCommitteeMessagesSubmitter()), + immediate.WithSyncCommitteeSubscriptionsSubmitter(mock.NewSyncCommitteeSubscriptionsSubmitter()), + immediate.WithSyncCommitteeContributionsSubmitter(mock.NewSyncCommitteeContributionsSubmitter()), + ) + require.NoError(t, err) + + opts := &api.SubmitExecutionPayloadEnvelopeOpts{} + require.NoError(t, service.SubmitExecutionPayloadEnvelope(ctx, opts)) + require.Same(t, opts, capture.opts) +} + +type capturingExecutionPayloadEnvelopeSubmitter struct { + opts *api.SubmitExecutionPayloadEnvelopeOpts +} + +func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(_ context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + s.opts = opts + return nil +} + +var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) diff --git a/services/submitter/multinode/parameters.go b/services/submitter/multinode/parameters.go index 9b0f5252..915d662a 100644 --- a/services/submitter/multinode/parameters.go +++ b/services/submitter/multinode/parameters.go @@ -31,6 +31,7 @@ type parameters struct { clientMonitor metrics.ClientMonitor processConcurrency int64 proposalSubmitters map[string]eth2client.ProposalSubmitter + executionPayloadEnvelopeSubmitters map[string]eth2client.ExecutionPayloadEnvelopeSubmitter attestationsSubmitters map[string]eth2client.AttestationsSubmitter aggregateAttestationsSubmitters map[string]eth2client.AggregateAttestationsSubmitter proposalPreparationsSubmitters map[string]eth2client.ProposalPreparationsSubmitter @@ -86,6 +87,13 @@ func WithProposalSubmitters(submitters map[string]eth2client.ProposalSubmitter) }) } +// WithExecutionPayloadEnvelopeSubmitters sets the execution payload envelope submitters. +func WithExecutionPayloadEnvelopeSubmitters(submitters map[string]eth2client.ExecutionPayloadEnvelopeSubmitter) Parameter { + return parameterFunc(func(p *parameters) { + p.executionPayloadEnvelopeSubmitters = submitters + }) +} + // WithAttestationsSubmitters sets the attestation submitters. func WithAttestationsSubmitters(submitters map[string]eth2client.AttestationsSubmitter) Parameter { return parameterFunc(func(p *parameters) { diff --git a/services/submitter/multinode/service.go b/services/submitter/multinode/service.go index f546bb87..a1d83979 100644 --- a/services/submitter/multinode/service.go +++ b/services/submitter/multinode/service.go @@ -31,6 +31,7 @@ type Service struct { timeout time.Duration processConcurrency int64 proposalSubmitters map[string]eth2client.ProposalSubmitter + executionPayloadEnvelopeSubmitters map[string]eth2client.ExecutionPayloadEnvelopeSubmitter attestationsSubmitters map[string]eth2client.AttestationsSubmitter aggregateAttestationsSubmitters map[string]eth2client.AggregateAttestationsSubmitter proposalPreparationsSubmitters map[string]eth2client.ProposalPreparationsSubmitter @@ -59,6 +60,7 @@ func New(_ context.Context, params ...Parameter) (*Service, error) { timeout: parameters.timeout, processConcurrency: parameters.processConcurrency, proposalSubmitters: parameters.proposalSubmitters, + executionPayloadEnvelopeSubmitters: parameters.executionPayloadEnvelopeSubmitters, attestationsSubmitters: parameters.attestationsSubmitters, aggregateAttestationsSubmitters: parameters.aggregateAttestationsSubmitters, proposalPreparationsSubmitters: parameters.proposalPreparationsSubmitters, diff --git a/services/submitter/multinode/submitexecutionpayloadenvelope.go b/services/submitter/multinode/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..616df399 --- /dev/null +++ b/services/submitter/multinode/submitexecutionpayloadenvelope.go @@ -0,0 +1,95 @@ +// 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 multinode + +import ( + "context" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/pkg/errors" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/sync/semaphore" +) + +// SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. +func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + ctx, span := otel.Tracer("attestantio.vouch.service.submitter.multinode").Start(ctx, "SubmitExecutionPayloadEnvelope", trace.WithAttributes( + attribute.String("strategy", "multinode"), + )) + defer span.End() + + if opts == nil { + return errors.New("no execution payload envelope supplied") + } + if len(s.executionPayloadEnvelopeSubmitters) == 0 { + return errors.New("no execution payload envelope submitters configured") + } + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + sem := semaphore.NewWeighted(s.processConcurrency) + submissionCompleted := make(chan struct{}, 1) + for name, submitter := range s.executionPayloadEnvelopeSubmitters { + go s.submitExecutionPayloadEnvelope(ctx, sem, submissionCompleted, name, opts, submitter) + } + + select { + case <-submissionCompleted: + return nil + case <-ctx.Done(): + return errors.New("no successful submissions before timeout") + } +} + +func (s *Service) submitExecutionPayloadEnvelope(ctx context.Context, + sem *semaphore.Weighted, + submissionCompleted chan<- struct{}, + name string, + opts *api.SubmitExecutionPayloadEnvelopeOpts, + submitter eth2client.ExecutionPayloadEnvelopeSubmitter, +) { + ctx, span := otel.Tracer("attestantio.vouch.service.submitter.multinode").Start(ctx, "submitExecutionPayloadEnvelope", trace.WithAttributes( + attribute.String("server", name), + )) + defer span.End() + + if err := sem.Acquire(ctx, 1); err != nil { + s.log.Error().Err(err).Msg("Failed to acquire semaphore") + return + } + defer sem.Release(1) + + address := "" + if service, isService := submitter.(eth2client.Service); isService { + address = service.Address() + } + started := time.Now() + err := submitter.SubmitExecutionPayloadEnvelope(ctx, opts) + s.clientMonitor.ClientOperation(address, "submit execution payload envelope", err == nil, time.Since(started)) + if err != nil { + s.log.Warn().Err(err).Msg("Failed to submit execution payload envelope") + return + } + + select { + case submissionCompleted <- struct{}{}: + default: + } + s.log.Trace().Msg("Submitted execution payload envelope") +} diff --git a/services/submitter/multinode/submitexecutionpayloadenvelope_test.go b/services/submitter/multinode/submitexecutionpayloadenvelope_test.go new file mode 100644 index 00000000..6179378e --- /dev/null +++ b/services/submitter/multinode/submitexecutionpayloadenvelope_test.go @@ -0,0 +1,243 @@ +// 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 multinode_test + +import ( + "context" + "testing" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/vouch/mock" + "github.com/attestantio/vouch/services/submitter/multinode" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestSubmitExecutionPayloadEnvelopeReturnsPromptlyAfterImmediateSuccess(t *testing.T) { + ctx := context.Background() + capture := &capturingExecutionPayloadEnvelopeSubmitter{} + service, err := multinode.New(ctx, + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(time.Second), + multinode.WithProcessConcurrency(1), + multinode.WithProposalSubmitters(map[string]eth2client.ProposalSubmitter{ + "one": mock.NewProposalSubmitter(), + }), + multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "one": capture, + }), + multinode.WithAttestationsSubmitters(map[string]eth2client.AttestationsSubmitter{ + "one": mock.NewAttestationsSubmitter(), + }), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(map[string]eth2client.BeaconCommitteeSubscriptionsSubmitter{ + "one": mock.NewBeaconCommitteeSubscriptionsSubmitter(), + }), + multinode.WithAggregateAttestationsSubmitters(map[string]eth2client.AggregateAttestationsSubmitter{ + "one": mock.NewAggregateAttestationsSubmitter(), + }), + multinode.WithProposalPreparationsSubmitters(map[string]eth2client.ProposalPreparationsSubmitter{ + "one": mock.NewProposalPreparationsSubmitter(), + }), + multinode.WithSyncCommitteeMessagesSubmitters(map[string]eth2client.SyncCommitteeMessagesSubmitter{ + "one": mock.NewSyncCommitteeMessagesSubmitter(), + }), + multinode.WithSyncCommitteeSubscriptionsSubmitters(map[string]eth2client.SyncCommitteeSubscriptionsSubmitter{ + "one": mock.NewSyncCommitteeSubscriptionsSubmitter(), + }), + multinode.WithSyncCommitteeContributionsSubmitters(map[string]eth2client.SyncCommitteeContributionsSubmitter{ + "one": mock.NewSyncCommitteeContributionsSubmitter(), + }), + ) + require.NoError(t, err) + + opts := &api.SubmitExecutionPayloadEnvelopeOpts{} + started := time.Now() + require.NoError(t, service.SubmitExecutionPayloadEnvelope(ctx, opts)) + require.Less(t, time.Since(started), 100*time.Millisecond) + require.Same(t, opts, capture.opts) +} + +func TestSubmitExecutionPayloadEnvelopeDoesNotWaitForNodeVersion(t *testing.T) { + ctx := context.Background() + nodeVersionRelease := make(chan struct{}) + submitter := &nodeVersionBlockingExecutionPayloadEnvelopeSubmitter{ + envelopeStarted: make(chan struct{}, 1), + nodeVersionStarted: make(chan struct{}, 1), + nodeVersionRelease: nodeVersionRelease, + } + defer close(nodeVersionRelease) + service, err := multinode.New(ctx, + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(time.Second), + multinode.WithProcessConcurrency(1), + multinode.WithProposalSubmitters(map[string]eth2client.ProposalSubmitter{ + "one": mock.NewProposalSubmitter(), + }), + multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "one": submitter, + }), + multinode.WithAttestationsSubmitters(map[string]eth2client.AttestationsSubmitter{ + "one": mock.NewAttestationsSubmitter(), + }), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(map[string]eth2client.BeaconCommitteeSubscriptionsSubmitter{ + "one": mock.NewBeaconCommitteeSubscriptionsSubmitter(), + }), + multinode.WithAggregateAttestationsSubmitters(map[string]eth2client.AggregateAttestationsSubmitter{ + "one": mock.NewAggregateAttestationsSubmitter(), + }), + multinode.WithProposalPreparationsSubmitters(map[string]eth2client.ProposalPreparationsSubmitter{ + "one": mock.NewProposalPreparationsSubmitter(), + }), + multinode.WithSyncCommitteeMessagesSubmitters(map[string]eth2client.SyncCommitteeMessagesSubmitter{ + "one": mock.NewSyncCommitteeMessagesSubmitter(), + }), + multinode.WithSyncCommitteeSubscriptionsSubmitters(map[string]eth2client.SyncCommitteeSubscriptionsSubmitter{ + "one": mock.NewSyncCommitteeSubscriptionsSubmitter(), + }), + multinode.WithSyncCommitteeContributionsSubmitters(map[string]eth2client.SyncCommitteeContributionsSubmitter{ + "one": mock.NewSyncCommitteeContributionsSubmitter(), + }), + ) + require.NoError(t, err) + + result := make(chan error, 1) + go func() { + result <- service.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{}) + }() + + select { + case <-submitter.envelopeStarted: + require.NoError(t, <-result) + case <-submitter.nodeVersionStarted: + t.Fatal("submission waited for node version") + case <-time.After(time.Second): + t.Fatal("submission did not start") + } +} + +func TestSubmitExecutionPayloadEnvelopeCancelsOnDeadline(t *testing.T) { + ctx := context.Background() + canceled := make(chan struct{}, 1) + service, err := multinode.New(ctx, + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(50*time.Millisecond), + multinode.WithProcessConcurrency(1), + multinode.WithProposalSubmitters(map[string]eth2client.ProposalSubmitter{ + "one": mock.NewProposalSubmitter(), + }), + multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "one": &blockingExecutionPayloadEnvelopeSubmitter{canceled: canceled}, + }), + multinode.WithAttestationsSubmitters(map[string]eth2client.AttestationsSubmitter{ + "one": mock.NewAttestationsSubmitter(), + }), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(map[string]eth2client.BeaconCommitteeSubscriptionsSubmitter{ + "one": mock.NewBeaconCommitteeSubscriptionsSubmitter(), + }), + multinode.WithAggregateAttestationsSubmitters(map[string]eth2client.AggregateAttestationsSubmitter{ + "one": mock.NewAggregateAttestationsSubmitter(), + }), + multinode.WithProposalPreparationsSubmitters(map[string]eth2client.ProposalPreparationsSubmitter{ + "one": mock.NewProposalPreparationsSubmitter(), + }), + multinode.WithSyncCommitteeMessagesSubmitters(map[string]eth2client.SyncCommitteeMessagesSubmitter{ + "one": mock.NewSyncCommitteeMessagesSubmitter(), + }), + multinode.WithSyncCommitteeSubscriptionsSubmitters(map[string]eth2client.SyncCommitteeSubscriptionsSubmitter{ + "one": mock.NewSyncCommitteeSubscriptionsSubmitter(), + }), + multinode.WithSyncCommitteeContributionsSubmitters(map[string]eth2client.SyncCommitteeContributionsSubmitter{ + "one": mock.NewSyncCommitteeContributionsSubmitter(), + }), + ) + require.NoError(t, err) + + err = service.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{}) + require.EqualError(t, err, "no successful submissions before timeout") + require.Eventually(t, func() bool { + select { + case <-canceled: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond) +} + +type capturingExecutionPayloadEnvelopeSubmitter struct { + opts *api.SubmitExecutionPayloadEnvelopeOpts +} + +func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(_ context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + s.opts = opts + return nil +} + +type blockingExecutionPayloadEnvelopeSubmitter struct { + canceled chan<- struct{} +} + +func (s *blockingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(ctx context.Context, + _ *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + <-ctx.Done() + s.canceled <- struct{}{} + return ctx.Err() +} + +type nodeVersionBlockingExecutionPayloadEnvelopeSubmitter struct { + envelopeStarted chan struct{} + nodeVersionStarted chan struct{} + nodeVersionRelease <-chan struct{} +} + +func (s *nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope( + _ context.Context, + _ *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + s.envelopeStarted <- struct{}{} + return nil +} + +func (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) Name() string { + return "test" +} + +func (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) Address() string { + return "http://test" +} + +func (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) IsActive() bool { + return true +} + +func (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) IsSynced() bool { + return true +} + +func (s *nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) NodeVersion( + _ context.Context, + _ *api.NodeVersionOpts, +) (*api.Response[string], error) { + s.nodeVersionStarted <- struct{}{} + <-s.nodeVersionRelease + return &api.Response[string]{Data: "test"}, nil +} + +var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) +var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) +var _ eth2client.NodeVersionProvider = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) +var _ eth2client.Service = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) diff --git a/services/submitter/service.go b/services/submitter/service.go index 1d363ed6..4ce714eb 100644 --- a/services/submitter/service.go +++ b/services/submitter/service.go @@ -36,6 +36,12 @@ type ProposalSubmitter interface { SubmitProposal(ctx context.Context, proposal *api.VersionedSignedProposal) error } +// ExecutionPayloadEnvelopeSubmitter is the interface for a submitter of execution payload envelopes. +type ExecutionPayloadEnvelopeSubmitter interface { + // SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. + SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error +} + // BeaconCommitteeSubscriptionsSubmitter is the interface for a submitter of beacon committee subscriptions. type BeaconCommitteeSubscriptionsSubmitter interface { // SubmitBeaconCommitteeSubscriptions submits a batch of beacon committee subscriptions. diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 50b2ca80..dc56e66d 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -22,6 +22,7 @@ import ( 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/beaconblockproposer" "github.com/attestantio/vouch/util" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -41,6 +42,197 @@ type beaconBlockError struct { err error } +// EPBSProposal provides the best ePBS proposal from a number of beacon nodes. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + ctx, span := otel.Tracer("attestantio.vouch.strategies.beaconblockproposal.best").Start(ctx, "EPBSProposal", trace.WithAttributes( + attribute.Int64("slot", util.SlotToInt64(opts.Slot)), + )) + defer span.End() + + started := time.Now() + log := util.LogWithID(ctx, s.log, "strategy_id").With().Uint64("slot", uint64(opts.Slot)).Logger() + ctx = log.WithContext(ctx) + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + softCtx, softCancel := context.WithTimeout(ctx, s.timeout/2) + defer softCancel() + + requests := len(s.proposalProviders) + respCh := make(chan *beaconBlockEPBSResponse, requests) + errCh := make(chan *beaconBlockError, requests) + for name, provider := range s.proposalProviders { + providerOpts := *opts + go s.epbsProposal(ctx, started, name, provider, respCh, errCh, &providerOpts, log) + } + + responded := 0 + errored := 0 + timedOut := 0 + softTimedOut := 0 + var bestProposal *api.VersionedEPBSProposal + var bestProvider string + for responded+errored+timedOut+softTimedOut != requests { + select { + case response := <-respCh: + responded++ + log.Trace(). + Dur("elapsed", time.Since(started)). + Str("provider", response.provider). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Msg("Response received") + if opts.IncludePayload != nil && *opts.IncludePayload && !response.proposal.ExecutionPayloadIncluded { + log.Warn().Str("provider", response.provider).Msg("Discarding ePBS proposal without requested execution payload") + continue + } + if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { + bestProposal = response.proposal + bestProvider = response.provider + } + case err := <-errCh: + errored++ + log.Debug(). + Dur("elapsed", time.Since(started)). + Str("provider", err.provider). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Err(err.err). + Msg("Error received") + case <-softCtx.Done(): + if bestProposal != nil { + timedOut = requests - responded - errored + log.Debug(). + Dur("elapsed", time.Since(started)). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Msg("Soft timeout reached with responses") + } else { + log.Debug(). + Dur("elapsed", time.Since(started)). + Int("errored", errored). + Msg("Soft timeout reached with no valid responses") + } + softTimedOut = requests - responded - errored - timedOut + } + } + softCancel() + + for responded+errored+timedOut != requests { + select { + case response := <-respCh: + responded++ + log.Trace(). + Dur("elapsed", time.Since(started)). + Str("provider", response.provider). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Msg("Response received") + if opts.IncludePayload != nil && *opts.IncludePayload && !response.proposal.ExecutionPayloadIncluded { + log.Warn().Str("provider", response.provider).Msg("Discarding ePBS proposal without requested execution payload") + continue + } + if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { + bestProposal = response.proposal + bestProvider = response.provider + } + case err := <-errCh: + errored++ + log.Debug(). + Dur("elapsed", time.Since(started)). + Str("provider", err.provider). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Err(err.err). + Msg("Error received") + case <-ctx.Done(): + timedOut = requests - responded - errored + log.Debug(). + Dur("elapsed", time.Since(started)). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Msg("Hard timeout reached") + } + } + + log.Trace(). + Dur("elapsed", time.Since(started)). + Int("responded", responded). + Int("errored", errored). + Int("timed_out", timedOut). + Msg("Results") + + if bestProposal == nil { + return nil, errors.New("no ePBS proposals received") + } + if bestProvider != "" { + s.clientMonitor.StrategyOperation("best", bestProvider, "ePBS beacon block proposal", time.Since(started)) + } + + return &api.Response[*api.VersionedEPBSProposal]{ + Data: bestProposal, + Metadata: make(map[string]any), + }, nil +} + +type beaconBlockEPBSResponse struct { + provider string + proposal *api.VersionedEPBSProposal +} + +func (s *Service) epbsProposal(ctx context.Context, + started time.Time, + name string, + provider beaconblockproposer.ProposalDataProvider, + respCh chan *beaconBlockEPBSResponse, + errCh chan *beaconBlockError, + opts *api.EPBSProposalOpts, + log zerolog.Logger, +) { + providerGraffiti := opts.Graffiti[:] + if bytes.Contains(providerGraffiti, []byte("{{CLIENT}}")) { + if nodeClientProvider, isProvider := provider.(eth2client.NodeClientProvider); isProvider { + nodeClientResponse, err := nodeClientProvider.NodeClient(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to obtain node client; not updating graffiti") + } else { + providerGraffiti = bytes.ReplaceAll(providerGraffiti, []byte("{{CLIENT}}"), []byte(nodeClientResponse.Data)) + } + if len(providerGraffiti) > 32 { + providerGraffiti = providerGraffiti[0:32] + } + opts.Graffiti = [32]byte{} + copy(opts.Graffiti[:], providerGraffiti) + } + } + + proposalResponse, err := provider.EPBSProposal(ctx, opts) + s.clientMonitor.ClientOperation(name, "ePBS beacon block proposal", err == nil, time.Since(started)) + if err != nil { + errCh <- &beaconBlockError{ + provider: name, + err: err, + } + + return + } + + respCh <- &beaconBlockEPBSResponse{ + provider: name, + proposal: proposalResponse.Data, + } +} + // Proposal provides the best beacon block proposal from a number of beacon nodes. func (s *Service) Proposal(ctx context.Context, opts *api.ProposalOpts, diff --git a/strategies/beaconblockproposal/best/beaconblockproposal_test.go b/strategies/beaconblockproposal/best/beaconblockproposal_test.go index 2fb4f273..86f5bb8a 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal_test.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal_test.go @@ -18,10 +18,10 @@ import ( "testing" "time" - eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/cache" mockcache "github.com/attestantio/vouch/services/cache/mock" standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" @@ -63,7 +63,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "good": mock.NewProposalProvider(), }), best.WithBlockRootToSlotCache(blockToSlotCache), @@ -79,7 +79,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "sleepy": mock.NewSleepyProposalProvider(5*time.Second, mock.NewProposalProvider()), }), best.WithBlockRootToSlotCache(blockToSlotCache), @@ -96,7 +96,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "error": mock.NewErroringProposalProvider(), "sleepy": mock.NewSleepyProposalProvider(time.Second, mock.NewProposalProvider()), }), @@ -113,7 +113,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "good": mock.NewProposalProvider(), "sleepy": mock.NewSleepyProposalProvider(2*time.Second, mock.NewProposalProvider()), }), @@ -131,7 +131,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "sleepy": mock.NewSleepyProposalProvider(2*time.Second, mock.NewProposalProvider()), }), best.WithBlockRootToSlotCache(blockToSlotCache), @@ -148,7 +148,7 @@ func TestProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(2), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "error": mock.NewErroringProposalProvider(), "sleepy": mock.NewSleepyProposalProvider(2*time.Second, mock.NewProposalProvider()), }), diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go new file mode 100644 index 00000000..1bbbd28f --- /dev/null +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -0,0 +1,383 @@ +// 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 best_test + +import ( + "context" + "errors" + "math/big" + "sync" + "testing" + "time" + + eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/vouch/mock" + "github.com/attestantio/vouch/services/beaconblockproposer" + "github.com/attestantio/vouch/services/cache" + mockcache "github.com/attestantio/vouch/services/cache/mock" + standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" + nullmetrics "github.com/attestantio/vouch/services/metrics/null" + "github.com/attestantio/vouch/strategies/beaconblockproposal/best" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestEPBSProposal(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + provider, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(1), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "one": provider, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: phase0.Slot(1), + }) + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.Data) +} + +func TestEPBSProposalReturnsIncludedCandidateAtSoftTimeout(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + includePayload := true + candidate := &api.VersionedEPBSProposal{ExecutionPayloadIncluded: true} + const timeout = 200 * time.Millisecond + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(1), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "included": &testEPBSProposalProvider{proposal: candidate}, + "error": &testEPBSProposalProvider{err: errors.New("failed")}, + "slow": &testEPBSProposalProvider{waitForCancellation: true}, + }), + best.WithTimeout(timeout), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + started := time.Now() + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{IncludePayload: &includePayload}) + elapsed := time.Since(started) + require.NoError(t, err) + require.Same(t, candidate, response.Data) + require.Less(t, elapsed, 3*timeout/4) +} + +func TestEPBSProposalPrefersIncludedCandidate(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + includePayload := true + includedCandidate := &api.VersionedEPBSProposal{ + ExecutionPayloadIncluded: true, + ConsensusValue: big.NewInt(1), + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(1), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "included": &testEPBSProposalProvider{proposal: includedCandidate}, + "external": &testEPBSProposalProvider{proposal: &api.VersionedEPBSProposal{ + ConsensusValue: big.NewInt(100), + }}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{IncludePayload: &includePayload}) + require.NoError(t, err) + require.Same(t, includedCandidate, response.Data) +} + +func TestEPBSProposalExpandsClientGraffitiPerProvider(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + firstProvider := &clientGraffitiEPBSProposalProvider{ + client: "first", + graffiti: make(chan [32]byte, 1), + } + const longClient = "second-client-version-with-more-than-thirty-two-bytes" + secondProvider := &clientGraffitiEPBSProposalProvider{ + client: longClient, + graffiti: make(chan [32]byte, 1), + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "first": firstProvider, + "second": secondProvider, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + var graffiti [32]byte + copy(graffiti[:], "{{CLIENT}}") + + _, err = service.EPBSProposal(ctx, &api.EPBSProposalOpts{Graffiti: graffiti}) + require.NoError(t, err) + var expectedFirst [32]byte + copy(expectedFirst[:], "first") + require.Equal(t, expectedFirst, <-firstProvider.graffiti) + var expectedSecond [32]byte + copy(expectedSecond[:], longClient) + require.Equal(t, expectedSecond, <-secondProvider.graffiti) +} + +func TestEPBSProposalStartsProvidersWhileGraffitiClientLookupIsSlow(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + slowProvider := &slowClientGraffitiEPBSProposalProvider{ + nodeClientStarted: make(chan struct{}), + release: make(chan struct{}), + } + releaseSlowProvider := slowProvider.release + t.Cleanup(func() { + select { + case <-releaseSlowProvider: + default: + close(releaseSlowProvider) + } + }) + healthyProvider := &waitingClientGraffitiEPBSProposalProvider{ + waitFor: slowProvider.nodeClientStarted, + proposalStarted: make(chan struct{}), + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "slow": slowProvider, + "healthy": healthyProvider, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + var graffiti [32]byte + copy(graffiti[:], "{{CLIENT}}") + + errCh := make(chan error, 1) + go func() { + _, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{Graffiti: graffiti}) + errCh <- err + }() + + select { + case <-slowProvider.nodeClientStarted: + case <-time.After(200 * time.Millisecond): + require.Fail(t, "slow provider client lookup did not start") + } + select { + case <-healthyProvider.proposalStarted: + case <-time.After(200 * time.Millisecond): + require.Fail(t, "healthy provider proposal did not start promptly") + } + close(releaseSlowProvider) + require.NoError(t, <-errCh) +} + +type testEPBSProposalProvider struct { + proposal *api.VersionedEPBSProposal + err error + waitForCancellation bool +} + +type clientGraffitiEPBSProposalProvider struct { + client string + graffiti chan [32]byte +} + +func (*clientGraffitiEPBSProposalProvider) Proposal( + _ context.Context, + _ *api.ProposalOpts, +) (*api.Response[*api.VersionedProposal], error) { + return nil, nil +} + +func (p *clientGraffitiEPBSProposalProvider) EPBSProposal( + _ context.Context, + opts *api.EPBSProposalOpts, +) (*api.Response[*api.VersionedEPBSProposal], error) { + p.graffiti <- opts.Graffiti + return &api.Response[*api.VersionedEPBSProposal]{Data: &api.VersionedEPBSProposal{}}, nil +} + +func (p *clientGraffitiEPBSProposalProvider) NodeClient( + _ context.Context, +) (*api.Response[string], error) { + return &api.Response[string]{Data: p.client}, nil +} + +var _ eth2client.NodeClientProvider = (*clientGraffitiEPBSProposalProvider)(nil) + +type slowClientGraffitiEPBSProposalProvider struct { + nodeClientStarted chan struct{} + release chan struct{} + startOnce sync.Once +} + +func (*slowClientGraffitiEPBSProposalProvider) Proposal( + _ context.Context, + _ *api.ProposalOpts, +) (*api.Response[*api.VersionedProposal], error) { + return nil, nil +} + +func (*slowClientGraffitiEPBSProposalProvider) EPBSProposal( + _ context.Context, + _ *api.EPBSProposalOpts, +) (*api.Response[*api.VersionedEPBSProposal], error) { + return &api.Response[*api.VersionedEPBSProposal]{Data: &api.VersionedEPBSProposal{}}, nil +} + +func (p *slowClientGraffitiEPBSProposalProvider) NodeClient( + ctx context.Context, +) (*api.Response[string], error) { + p.startOnce.Do(func() { + close(p.nodeClientStarted) + }) + select { + case <-p.release: + return &api.Response[string]{Data: "slow"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +var _ eth2client.NodeClientProvider = (*slowClientGraffitiEPBSProposalProvider)(nil) + +type waitingClientGraffitiEPBSProposalProvider struct { + waitFor <-chan struct{} + proposalStarted chan struct{} + proposalOnce sync.Once +} + +func (*waitingClientGraffitiEPBSProposalProvider) Proposal( + _ context.Context, + _ *api.ProposalOpts, +) (*api.Response[*api.VersionedProposal], error) { + return nil, nil +} + +func (p *waitingClientGraffitiEPBSProposalProvider) EPBSProposal( + _ context.Context, + _ *api.EPBSProposalOpts, +) (*api.Response[*api.VersionedEPBSProposal], error) { + p.proposalOnce.Do(func() { + close(p.proposalStarted) + }) + return &api.Response[*api.VersionedEPBSProposal]{Data: &api.VersionedEPBSProposal{}}, nil +} + +func (p *waitingClientGraffitiEPBSProposalProvider) NodeClient( + ctx context.Context, +) (*api.Response[string], error) { + select { + case <-p.waitFor: + return &api.Response[string]{Data: "healthy"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +var _ eth2client.NodeClientProvider = (*waitingClientGraffitiEPBSProposalProvider)(nil) + +func (p *testEPBSProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { + return nil, nil +} + +func (p *testEPBSProposalProvider) EPBSProposal(ctx context.Context, + _ *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if p.waitForCancellation { + <-ctx.Done() + return nil, ctx.Err() + } + if p.err != nil { + return nil, p.err + } + + return &api.Response[*api.VersionedEPBSProposal]{Data: p.proposal}, nil +} diff --git a/strategies/beaconblockproposal/best/parameters.go b/strategies/beaconblockproposal/best/parameters.go index e331f169..32884406 100644 --- a/strategies/beaconblockproposal/best/parameters.go +++ b/strategies/beaconblockproposal/best/parameters.go @@ -19,6 +19,7 @@ import ( "time" eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/cache" "github.com/attestantio/vouch/services/chaintime" "github.com/attestantio/vouch/services/metrics" @@ -33,7 +34,7 @@ type parameters struct { processConcurrency int64 chainTime chaintime.Service specProvider eth2client.SpecProvider - proposalProviders map[string]eth2client.ProposalProvider + proposalProviders map[string]beaconblockproposer.ProposalDataProvider timeout time.Duration blockRootToSlotCache cache.BlockRootToSlotProvider executionPayloadFactor float64 @@ -93,7 +94,7 @@ func WithSpecProvider(provider eth2client.SpecProvider) Parameter { } // WithProposalProviders sets the proposal providers. -func WithProposalProviders(providers map[string]eth2client.ProposalProvider) Parameter { +func WithProposalProviders(providers map[string]beaconblockproposer.ProposalDataProvider) Parameter { return parameterFunc(func(p *parameters) { p.proposalProviders = providers }) diff --git a/strategies/beaconblockproposal/best/service.go b/strategies/beaconblockproposal/best/service.go index 64b1768f..b264bc41 100644 --- a/strategies/beaconblockproposal/best/service.go +++ b/strategies/beaconblockproposal/best/service.go @@ -17,8 +17,8 @@ import ( "context" "time" - eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/cache" "github.com/attestantio/vouch/services/chaintime" "github.com/attestantio/vouch/services/metrics" @@ -33,7 +33,7 @@ type Service struct { clientMonitor metrics.ClientMonitor processConcurrency int64 chainTime chaintime.Service - proposalProviders map[string]eth2client.ProposalProvider + proposalProviders map[string]beaconblockproposer.ProposalDataProvider timeout time.Duration blockRootToSlotCache cache.BlockRootToSlotProvider executionPayloadFactor float64 diff --git a/strategies/beaconblockproposal/best/service_test.go b/strategies/beaconblockproposal/best/service_test.go index 9c478dbe..69e05afd 100644 --- a/strategies/beaconblockproposal/best/service_test.go +++ b/strategies/beaconblockproposal/best/service_test.go @@ -18,9 +18,9 @@ import ( "testing" "time" - eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/cache" mockcache "github.com/attestantio/vouch/services/cache/mock" standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" @@ -60,7 +60,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -77,7 +77,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -95,7 +95,7 @@ func TestService(t *testing.T) { best.WithSpecProvider(specProvider), best.WithTimeout(0), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -112,7 +112,7 @@ func TestService(t *testing.T) { best.WithClientMonitor(nullmetrics.New()), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -128,7 +128,7 @@ func TestService(t *testing.T) { best.WithClientMonitor(nullmetrics.New()), best.WithChainTimeService(chainTime), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -146,7 +146,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(0), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -177,7 +177,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{}), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{}), best.WithBlockRootToSlotCache(blockToSlotCache), }, err: "problem with parameters: no proposal providers specified", @@ -191,7 +191,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(mock.NewErroringSpecProvider()), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), @@ -209,7 +209,7 @@ func TestService(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProcessConcurrency(1), - best.WithProposalProviders(map[string]eth2client.ProposalProvider{ + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": mock.NewProposalProvider(), "two": mock.NewProposalProvider(), "three": mock.NewProposalProvider(), diff --git a/strategies/beaconblockproposal/first/epbsproposal_test.go b/strategies/beaconblockproposal/first/epbsproposal_test.go new file mode 100644 index 00000000..57f99106 --- /dev/null +++ b/strategies/beaconblockproposal/first/epbsproposal_test.go @@ -0,0 +1,103 @@ +// 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_test + +import ( + "context" + "runtime" + "strings" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/api" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/vouch/services/beaconblockproposer" + nullmetrics "github.com/attestantio/vouch/services/metrics/null" + "github.com/attestantio/vouch/strategies/beaconblockproposal/first" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestEPBSProposal(t *testing.T) { + ctx := context.Background() + provider, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "one": provider, + }), + first.WithTimeout(time.Second), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: phase0.Slot(1), + }) + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.Data) +} + +func TestEPBSProposalDoesNotLeaveLateProvidersBlocked(t *testing.T) { + ctx := context.Background() + release := make(chan struct{}) + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "fast": &epbsProposalProvider{proposal: &api.VersionedEPBSProposal{}}, + "late1": &epbsProposalProvider{proposal: &api.VersionedEPBSProposal{}, release: release}, + "late2": &epbsProposalProvider{proposal: &api.VersionedEPBSProposal{}, release: release}, + }), + first.WithTimeout(time.Second), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.NotNil(t, response) + close(release) + + require.Eventually(t, func() bool { + stack := make([]byte, 64*1024) + stackLength := runtime.Stack(stack, true) + return !strings.Contains(string(stack[:stackLength]), "strategies/beaconblockproposal/first.(*Service).EPBSProposal.func1") + }, time.Second, 10*time.Millisecond) +} + +type epbsProposalProvider struct { + proposal *api.VersionedEPBSProposal + release <-chan struct{} +} + +func (p *epbsProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { + return nil, nil +} + +func (p *epbsProposalProvider) EPBSProposal(_ context.Context, + _ *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + if p.release != nil { + <-p.release + } + + return &api.Response[*api.VersionedEPBSProposal]{Data: p.proposal}, nil +} diff --git a/strategies/beaconblockproposal/first/parameters.go b/strategies/beaconblockproposal/first/parameters.go index 70e7abc2..524954ea 100644 --- a/strategies/beaconblockproposal/first/parameters.go +++ b/strategies/beaconblockproposal/first/parameters.go @@ -18,7 +18,7 @@ package first import ( "time" - eth2client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/metrics" nullmetrics "github.com/attestantio/vouch/services/metrics/null" "github.com/pkg/errors" @@ -28,7 +28,7 @@ import ( type parameters struct { logLevel zerolog.Level clientMonitor metrics.ClientMonitor - proposalProviders map[string]eth2client.ProposalProvider + proposalProviders map[string]beaconblockproposer.ProposalDataProvider timeout time.Duration } @@ -58,7 +58,7 @@ func WithClientMonitor(monitor metrics.ClientMonitor) Parameter { } // WithProposalProviders sets the beacon block proposal providers. -func WithProposalProviders(providers map[string]eth2client.ProposalProvider) Parameter { +func WithProposalProviders(providers map[string]beaconblockproposer.ProposalDataProvider) Parameter { return parameterFunc(func(p *parameters) { p.proposalProviders = providers }) diff --git a/strategies/beaconblockproposal/first/service.go b/strategies/beaconblockproposal/first/service.go index bb635846..014d43ba 100644 --- a/strategies/beaconblockproposal/first/service.go +++ b/strategies/beaconblockproposal/first/service.go @@ -19,6 +19,7 @@ import ( eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/metrics" "github.com/attestantio/vouch/util" "github.com/pkg/errors" @@ -33,10 +34,63 @@ import ( type Service struct { log zerolog.Logger clientMonitor metrics.ClientMonitor - proposalProviders map[string]eth2client.ProposalProvider + proposalProviders map[string]beaconblockproposer.ProposalDataProvider timeout time.Duration } +// EPBSProposal provides the first ePBS proposal from a number of beacon nodes. +func (s *Service) EPBSProposal(ctx context.Context, + opts *api.EPBSProposalOpts, +) ( + *api.Response[*api.VersionedEPBSProposal], + error, +) { + ctx, span := otel.Tracer("attestantio.vouch.strategies.beaconblockproposal.first").Start(ctx, "EPBSProposal", trace.WithAttributes( + attribute.Int64("slot", util.SlotToInt64(opts.Slot)), + )) + defer span.End() + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + + proposalCh := make(chan *api.VersionedEPBSProposal, len(s.proposalProviders)) + for name, provider := range s.proposalProviders { + go func(ctx context.Context, name string, provider beaconblockproposer.ProposalDataProvider, ch chan *api.VersionedEPBSProposal) { + log := s.log.With().Str("provider", name).Uint64("slot", uint64(opts.Slot)).Logger() + + started := time.Now() + proposalResponse, err := provider.EPBSProposal(ctx, opts) + s.clientMonitor.ClientOperation(name, "ePBS beacon block proposal", err == nil, time.Since(started)) + if err != nil { + if !errors.Is(err, context.Canceled) { + log.Debug().Err(err).Msg("Failed to obtain ePBS beacon block proposal") + } + + return + } + proposal := proposalResponse.Data + log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained ePBS beacon block proposal") + + select { + case ch <- proposal: + case <-ctx.Done(): + } + }(ctx, name, provider, proposalCh) + } + + select { + case <-ctx.Done(): + cancel() + s.log.Debug().Msg("Failed to obtain ePBS beacon block proposal before timeout") + return nil, errors.New("failed to obtain ePBS beacon block proposal before timeout") + case proposal := <-proposalCh: + cancel() + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: make(map[string]any), + }, nil + } +} + // New creates a new beacon block proposal strategy. func New(_ context.Context, params ...Parameter) (*Service, error) { parameters, err := parseAndCheckParameters(params...) From 0bfd07c12c9adfc3526227c34c0732ee83be1cc1 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Tue, 11 Aug 2026 19:24:28 +0200 Subject: [PATCH 02/30] Support custom beacon-node presets --- clients.go | 1 + clients_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 3 ++ main.go | 1 + 4 files changed, 102 insertions(+) create mode 100644 clients_test.go diff --git a/clients.go b/clients.go index af812b13..1f60afd8 100644 --- a/clients.go +++ b/clients.go @@ -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), diff --git a/clients_test.go b/clients_test.go new file mode 100644 index 00000000..538550bc --- /dev/null +++ b/clients_test.go @@ -0,0 +1,97 @@ +// 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" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/gloas" + "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) +} diff --git a/docs/configuration.md b/docs/configuration.md index f9c081ed..8e9b3dd6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/main.go b/main.go index 8cb5a4aa..eade2365 100644 --- a/main.go +++ b/main.go @@ -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) From 38655322b282b8b021de423accf6dbadeb465a04 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 12:15:24 +0200 Subject: [PATCH 03/30] Sign the payload envelope before publishing the Gloas block proposeEPBSBlock submitted the signed beacon block and only then signed the execution payload envelope and fetched its KZG proofs and blobs. Any failure in that later work left an on-chain block committing to a payload that is never revealed, costing the execution rewards and exposing the proposer to the payload-withheld penalty. The failure is reachable in practice: the signer treats DOMAIN_BEACON_BUILDER as optional, so a beacon node whose spec omits it yields a signer that cannot sign, and a dropped dirk connection between the two signatures does the same. Prepare the envelope in full before submitting the block so the path fails closed. Ordering only; no behaviour or error strings otherwise changed. --- .../standard/gloas_test.go | 22 ++++++++++++++++++- .../beaconblockproposer/standard/propose.go | 9 ++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index b4be4c20..25f72f74 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -46,6 +46,7 @@ func TestProposeGloas(t *testing.T) { name string executionPayloadIncluded bool envelopeRootMismatch bool + envelopeSignerErr error err string }{ { @@ -63,6 +64,12 @@ func TestProposeGloas(t *testing.T) { envelopeRootMismatch: true, err: "failed to propose block: ePBS execution payload envelope is for incorrect block", }, + { + name: "EnvelopeSigningFailure", + executionPayloadIncluded: true, + envelopeSignerErr: errors.New("envelope signing failed"), + err: "failed to propose block: failed to sign execution payload envelope: envelope signing failed", + }, } for _, test := range tests { @@ -103,7 +110,7 @@ func TestProposeGloas(t *testing.T) { signature := phase0.BLSSignature{0x01} blockSigner := &capturingBeaconBlockSigner{signature: signature} envelopeSignature := phase0.BLSSignature{0x03} - envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature} + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature, err: test.envelopeSignerErr} envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} service, err := standard.New(ctx, @@ -135,11 +142,18 @@ func TestProposeGloas(t *testing.T) { if test.err != "" { require.EqualError(t, err, test.err) require.Nil(t, proposalSubmitter.proposal) + require.Zero(t, proposalSubmitter.calls) if test.envelopeRootMismatch { require.Zero(t, blockSigner.calls) require.Zero(t, envelopeSigner.calls) require.Nil(t, envelopeSubmitter.opts) } + if test.envelopeSignerErr != nil { + require.Equal(t, 1, blockSigner.calls) + require.Equal(t, 1, envelopeSigner.calls) + require.Same(t, responseProposal.GloasContents.ExecutionPayloadEnvelope, envelopeSigner.envelope) + require.Nil(t, envelopeSubmitter.opts) + } } else { require.NoError(t, err) require.NotNil(t, proposalSubmitter.proposal) @@ -198,9 +212,11 @@ func TestProposePreGloas(t *testing.T) { type capturingProposalSubmitter struct { proposal *consensusapi.VersionedSignedProposal + calls int } func (s *capturingProposalSubmitter) SubmitProposal(_ context.Context, proposal *consensusapi.VersionedSignedProposal) error { + s.calls++ s.proposal = proposal return nil @@ -217,6 +233,7 @@ type capturingExecutionPayloadEnvelopeSigner struct { signature phase0.BLSSignature envelope *gloas.ExecutionPayloadEnvelope calls int + err error } func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( @@ -227,6 +244,9 @@ func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( ) (phase0.BLSSignature, error) { s.calls++ s.envelope = envelope + if s.err != nil { + return phase0.BLSSignature{}, s.err + } return s.signature, nil } diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 43a8e31e..66ff1643 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -267,10 +267,6 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return err } - if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { - return errors.Wrap(err, "failed to submit proposal") - } - signature, err := s.executionPayloadEnvelopeSigner.SignExecutionPayloadEnvelope(ctx, duty.Account(), duty.Slot(), envelope) if err != nil { return errors.Wrap(err, "failed to sign execution payload envelope") @@ -283,6 +279,11 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if err != nil { return errors.Wrap(err, "failed to obtain execution payload envelope blobs") } + + if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { + return errors.Wrap(err, "failed to submit proposal") + } + if err := s.executionPayloadEnvelopeSubmitter.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{ SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ Version: spec.DataVersionGloas, From 181b455353b4c615bcd738c3e7872748b4b1369f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 13:06:43 +0200 Subject: [PATCH 04/30] Let every node receive the execution payload envelope SubmitExecutionPayloadEnvelope handed the same cancellable context to every per-node goroutine and cancelled it as soon as the first node acknowledged, aborting the in-flight submissions to all the others. If the one node that acknowledged then failed to gossip the envelope the payload was never revealed, costing the execution rewards and exposing the proposer to the payload-withheld penalty. That is the single point of failure the multinode submitter exists to remove, and the sibling SubmitProposal avoids it by never cancelling early. Release the context once every submission has finished rather than on first success, so submissions remain bounded by the timeout but are no longer aborted by a peer. Releasing the context that way lets both arms of the select become ready at once, and select then picks between them at random, so a successful submission could report "no successful submissions before timeout". Record success in a flag and consult that instead of the chosen arm. --- .../submitexecutionpayloadenvelope.go | 27 ++++++- .../submitexecutionpayloadenvelope_test.go | 78 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/services/submitter/multinode/submitexecutionpayloadenvelope.go b/services/submitter/multinode/submitexecutionpayloadenvelope.go index 616df399..a44d92a4 100644 --- a/services/submitter/multinode/submitexecutionpayloadenvelope.go +++ b/services/submitter/multinode/submitexecutionpayloadenvelope.go @@ -15,6 +15,8 @@ package multinode import ( "context" + "sync" + "sync/atomic" "time" eth2client "github.com/attestantio/go-eth2-client" @@ -41,25 +43,43 @@ func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api. } ctx, cancel := context.WithTimeout(ctx, s.timeout) - defer cancel() sem := semaphore.NewWeighted(s.processConcurrency) submissionCompleted := make(chan struct{}, 1) + submissionSucceeded := &atomic.Bool{} + var wg sync.WaitGroup for name, submitter := range s.executionPayloadEnvelopeSubmitters { - go s.submitExecutionPayloadEnvelope(ctx, sem, submissionCompleted, name, opts, submitter) + wg.Go(func() { + s.submitExecutionPayloadEnvelope(ctx, sem, submissionCompleted, submissionSucceeded, name, opts, submitter) + }) } + // Release the timeout context once every submission has finished, rather than as soon as + // the first one succeeds, so that one node's success does not abort the others' in-flight + // submissions. + go func() { + wg.Wait() + cancel() + }() select { case <-submissionCompleted: - return nil case <-ctx.Done(): + } + + // The context is released once every submission has finished, so both cases above can be + // ready at once and select picks between them at random. Consult the success flag rather + // than the chosen case, otherwise a successful submission can report a timeout. + if !submissionSucceeded.Load() { return errors.New("no successful submissions before timeout") } + + return nil } func (s *Service) submitExecutionPayloadEnvelope(ctx context.Context, sem *semaphore.Weighted, submissionCompleted chan<- struct{}, + submissionSucceeded *atomic.Bool, name string, opts *api.SubmitExecutionPayloadEnvelopeOpts, submitter eth2client.ExecutionPayloadEnvelopeSubmitter, @@ -87,6 +107,7 @@ func (s *Service) submitExecutionPayloadEnvelope(ctx context.Context, return } + submissionSucceeded.Store(true) select { case submissionCompleted <- struct{}{}: default: diff --git a/services/submitter/multinode/submitexecutionpayloadenvelope_test.go b/services/submitter/multinode/submitexecutionpayloadenvelope_test.go index 6179378e..d93af2be 100644 --- a/services/submitter/multinode/submitexecutionpayloadenvelope_test.go +++ b/services/submitter/multinode/submitexecutionpayloadenvelope_test.go @@ -177,6 +177,64 @@ func TestSubmitExecutionPayloadEnvelopeCancelsOnDeadline(t *testing.T) { }, time.Second, 10*time.Millisecond) } +func TestSubmitExecutionPayloadEnvelopeLetsSlowSubmitterCompleteAfterFirstSuccess(t *testing.T) { + ctx := context.Background() + completed := make(chan struct{}, 1) + cancelled := make(chan struct{}, 1) + fast := &capturingExecutionPayloadEnvelopeSubmitter{} + slow := &slowExecutionPayloadEnvelopeSubmitter{ + delay: 100 * time.Millisecond, + completed: completed, + cancelled: cancelled, + } + service, err := multinode.New(ctx, + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(time.Second), + multinode.WithProcessConcurrency(2), + multinode.WithProposalSubmitters(map[string]eth2client.ProposalSubmitter{ + "one": mock.NewProposalSubmitter(), + }), + multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "fast": fast, + "slow": slow, + }), + multinode.WithAttestationsSubmitters(map[string]eth2client.AttestationsSubmitter{ + "one": mock.NewAttestationsSubmitter(), + }), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(map[string]eth2client.BeaconCommitteeSubscriptionsSubmitter{ + "one": mock.NewBeaconCommitteeSubscriptionsSubmitter(), + }), + multinode.WithAggregateAttestationsSubmitters(map[string]eth2client.AggregateAttestationsSubmitter{ + "one": mock.NewAggregateAttestationsSubmitter(), + }), + multinode.WithProposalPreparationsSubmitters(map[string]eth2client.ProposalPreparationsSubmitter{ + "one": mock.NewProposalPreparationsSubmitter(), + }), + multinode.WithSyncCommitteeMessagesSubmitters(map[string]eth2client.SyncCommitteeMessagesSubmitter{ + "one": mock.NewSyncCommitteeMessagesSubmitter(), + }), + multinode.WithSyncCommitteeSubscriptionsSubmitters(map[string]eth2client.SyncCommitteeSubscriptionsSubmitter{ + "one": mock.NewSyncCommitteeSubscriptionsSubmitter(), + }), + multinode.WithSyncCommitteeContributionsSubmitters(map[string]eth2client.SyncCommitteeContributionsSubmitter{ + "one": mock.NewSyncCommitteeContributionsSubmitter(), + }), + ) + require.NoError(t, err) + + require.NoError(t, service.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{})) + + select { + case <-completed: + // The slow submitter finished its own submission, as required: the fast peer's + // success must not abort it. + case <-cancelled: + t.Fatal("slow submitter was cancelled instead of completing its submission") + case <-time.After(time.Second): + t.Fatal("slow submitter did not finish its submission") + } +} + type capturingExecutionPayloadEnvelopeSubmitter struct { opts *api.SubmitExecutionPayloadEnvelopeOpts } @@ -237,7 +295,27 @@ func (s *nodeVersionBlockingExecutionPayloadEnvelopeSubmitter) NodeVersion( return &api.Response[string]{Data: "test"}, nil } +type slowExecutionPayloadEnvelopeSubmitter struct { + delay time.Duration + completed chan<- struct{} + cancelled chan<- struct{} +} + +func (s *slowExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(ctx context.Context, + _ *api.SubmitExecutionPayloadEnvelopeOpts, +) error { + select { + case <-time.After(s.delay): + s.completed <- struct{}{} + return nil + case <-ctx.Done(): + s.cancelled <- struct{}{} + return ctx.Err() + } +} + var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) +var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*slowExecutionPayloadEnvelopeSubmitter)(nil) var _ eth2client.ExecutionPayloadEnvelopeSubmitter = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) var _ eth2client.NodeVersionProvider = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) var _ eth2client.Service = (*nodeVersionBlockingExecutionPayloadEnvelopeSubmitter)(nil) From d3442bfe68d282cb2424000e64558c62a995ed17 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 16:17:02 +0200 Subject: [PATCH 05/30] Reject ePBS proposals with a zero fee recipient The best strategy passed whatever Gloas proposal a beacon node returned straight through to scoring, without inspecting the execution payload bid that the block commits to. A node building against an unset fee recipient therefore yielded a proposal that vouch would sign and publish, directing that block's execution layer rewards to the zero address. Report such a proposal as a provider error so the strategy discards it and settles on another node's proposal instead. --- .../best/beaconblockproposal.go | 15 +++ .../best/epbsproposal_test.go | 99 ++++++++++++++++++- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index dc56e66d..021f1f54 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -227,6 +227,21 @@ func (s *Service) epbsProposal(ctx context.Context, return } + if proposalResponse.Data != nil && proposalResponse.Data.Version == spec.DataVersionGloas { + block := proposalResponse.Data.Gloas + if proposalResponse.Data.ExecutionPayloadIncluded { + block = proposalResponse.Data.GloasContents.Block + } + if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { + errCh <- &beaconBlockError{ + provider: name, + err: errors.New("beacon block obtained with 0 fee recipient"), + } + + return + } + } + respCh <- &beaconBlockEPBSResponse{ provider: name, proposal: proposalResponse.Data, diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index 1bbbd28f..e11d8c7e 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -23,7 +23,10 @@ import ( eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" - mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "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/mock" "github.com/attestantio/vouch/services/beaconblockproposer" @@ -45,8 +48,6 @@ func TestEPBSProposal(t *testing.T) { standardchaintime.WithSpecProvider(specProvider), ) require.NoError(t, err) - provider, err := mockconsensusclient.New(ctx) - require.NoError(t, err) cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) service, err := best.New(ctx, @@ -56,7 +57,7 @@ func TestEPBSProposal(t *testing.T) { best.WithChainTimeService(chainTime), best.WithSpecProvider(specProvider), best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ - "one": provider, + "one": &testEPBSProposalProvider{proposal: testGloasProposal(1, bellatrix.ExecutionAddress{0x01})}, }), best.WithTimeout(time.Second), best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), @@ -145,6 +146,96 @@ func TestEPBSProposalPrefersIncludedCandidate(t *testing.T) { require.Same(t, includedCandidate, response.Data) } +func TestEPBSProposalRejectsZeroFeeRecipient(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + zeroFeeCandidate := testGloasProposal(100, bellatrix.ExecutionAddress{}) + validCandidate := testGloasProposal(1, bellatrix.ExecutionAddress{0x01}) + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "zero-fee": &testEPBSProposalProvider{proposal: zeroFeeCandidate}, + "valid": &testEPBSProposalProvider{proposal: validCandidate}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.Same(t, validCandidate, response.Data) +} + +func TestEPBSProposalRejectsZeroFeeRecipientWithoutPayload(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + zeroFeeCandidate := testGloasProposalWithoutPayload(100, bellatrix.ExecutionAddress{}) + validCandidate := testGloasProposalWithoutPayload(1, bellatrix.ExecutionAddress{0x01}) + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "zero-fee": &testEPBSProposalProvider{proposal: zeroFeeCandidate}, + "valid": &testEPBSProposalProvider{proposal: validCandidate}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.Same(t, validCandidate, response.Data) +} + +func testGloasProposal(value int64, feeRecipient bellatrix.ExecutionAddress) *api.VersionedEPBSProposal { + return &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + ConsensusValue: big.NewInt(value), + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{FeeRecipient: feeRecipient}, + }, + }, + }, + }, + } +} + +func testGloasProposalWithoutPayload(value int64, feeRecipient bellatrix.ExecutionAddress) *api.VersionedEPBSProposal { + proposal := testGloasProposal(value, feeRecipient) + proposal.Gloas = proposal.GloasContents.Block + proposal.GloasContents = nil + proposal.ExecutionPayloadIncluded = false + + return proposal +} + func TestEPBSProposalExpandsClientGraffitiPerProvider(t *testing.T) { ctx := context.Background() specProvider := mock.NewSpecProvider() From c207a4da159605cb3f9431acae13fd4adcb8aa20 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 16:34:54 +0200 Subject: [PATCH 06/30] Preserve ePBS graffiti when the node client lookup fails providerGraffiti aliases the backing array of opts.Graffiti. When the {{CLIENT}} substitution succeeds bytes.ReplaceAll returns a fresh slice and the alias is broken, but when NodeClient fails the alias survives and the following "opts.Graffiti = [32]byte{}" zeroes the very array being copied from. The proposal was then requested with empty graffiti rather than the operator's configured value. Copy into a local array and assign that afterwards, so the source is never clobbered part way through. --- .../best/beaconblockproposal.go | 5 ++- .../best/epbsproposal_test.go | 43 ++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 021f1f54..5ff41730 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -211,8 +211,9 @@ func (s *Service) epbsProposal(ctx context.Context, if len(providerGraffiti) > 32 { providerGraffiti = providerGraffiti[0:32] } - opts.Graffiti = [32]byte{} - copy(opts.Graffiti[:], providerGraffiti) + var graffiti [32]byte + copy(graffiti[:], providerGraffiti) + opts.Graffiti = graffiti } } diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index e11d8c7e..d232405b 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -282,6 +282,41 @@ func TestEPBSProposalExpandsClientGraffitiPerProvider(t *testing.T) { require.Equal(t, expectedSecond, <-secondProvider.graffiti) } +func TestEPBSProposalPreservesGraffitiWhenClientLookupFails(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + provider := &clientGraffitiEPBSProposalProvider{ + nodeClientErr: errors.New("node client unavailable"), + graffiti: make(chan [32]byte, 1), + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(1), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "provider": provider, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + var graffiti [32]byte + copy(graffiti[:], "configured {{CLIENT}}") + _, err = service.EPBSProposal(ctx, &api.EPBSProposalOpts{Graffiti: graffiti}) + require.NoError(t, err) + require.Equal(t, graffiti, <-provider.graffiti) +} + func TestEPBSProposalStartsProvidersWhileGraffitiClientLookupIsSlow(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -353,8 +388,9 @@ type testEPBSProposalProvider struct { } type clientGraffitiEPBSProposalProvider struct { - client string - graffiti chan [32]byte + client string + nodeClientErr error + graffiti chan [32]byte } func (*clientGraffitiEPBSProposalProvider) Proposal( @@ -375,6 +411,9 @@ func (p *clientGraffitiEPBSProposalProvider) EPBSProposal( func (p *clientGraffitiEPBSProposalProvider) NodeClient( _ context.Context, ) (*api.Response[string], error) { + if p.nodeClientErr != nil { + return nil, p.nodeClientErr + } return &api.Response[string]{Data: p.client}, nil } From 249ac668618b0c77a88ca55bf087b9b25a5cbb91 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 16:58:01 +0200 Subject: [PATCH 07/30] Honour the requested payload inclusion in the first strategy EPBSProposal returned the first proposal to arrive even when it omitted the execution payload that opts.IncludePayload asked for. The proposer forces local building precisely so that it holds the payload it must later reveal, so accepting a proposal without one defeats the request. Keep waiting for a conforming proposal rather than returning the first arrival, falling back to the existing timeout if none appears. --- .../first/epbsproposal_test.go | 42 +++++++++++++++++++ .../beaconblockproposal/first/service.go | 28 ++++++++----- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/strategies/beaconblockproposal/first/epbsproposal_test.go b/strategies/beaconblockproposal/first/epbsproposal_test.go index 57f99106..d8ab855d 100644 --- a/strategies/beaconblockproposal/first/epbsproposal_test.go +++ b/strategies/beaconblockproposal/first/epbsproposal_test.go @@ -80,6 +80,48 @@ func TestEPBSProposalDoesNotLeaveLateProvidersBlocked(t *testing.T) { }, time.Second, 10*time.Millisecond) } +func TestEPBSProposalSkipsProposalWithoutRequestedPayload(t *testing.T) { + ctx := context.Background() + includePayload := true + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "excluded": &epbsProposalProvider{proposal: &api.VersionedEPBSProposal{}}, + }), + first.WithTimeout(10*time.Millisecond), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{IncludePayload: &includePayload}) + require.Nil(t, response) + require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") +} + +func TestEPBSProposalWaitsForProposalWithRequestedPayload(t *testing.T) { + ctx := context.Background() + includePayload := true + release := make(chan struct{}) + included := &api.VersionedEPBSProposal{ExecutionPayloadIncluded: true} + time.AfterFunc(20*time.Millisecond, func() { + close(release) + }) + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "excluded": &epbsProposalProvider{proposal: &api.VersionedEPBSProposal{}}, + "included": &epbsProposalProvider{proposal: included, release: release}, + }), + first.WithTimeout(time.Second), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{IncludePayload: &includePayload}) + require.NoError(t, err) + require.Same(t, included, response.Data) +} + type epbsProposalProvider struct { proposal *api.VersionedEPBSProposal release <-chan struct{} diff --git a/strategies/beaconblockproposal/first/service.go b/strategies/beaconblockproposal/first/service.go index 014d43ba..a9acf5ac 100644 --- a/strategies/beaconblockproposal/first/service.go +++ b/strategies/beaconblockproposal/first/service.go @@ -77,17 +77,23 @@ func (s *Service) EPBSProposal(ctx context.Context, }(ctx, name, provider, proposalCh) } - select { - case <-ctx.Done(): - cancel() - s.log.Debug().Msg("Failed to obtain ePBS beacon block proposal before timeout") - return nil, errors.New("failed to obtain ePBS beacon block proposal before timeout") - case proposal := <-proposalCh: - cancel() - return &api.Response[*api.VersionedEPBSProposal]{ - Data: proposal, - Metadata: make(map[string]any), - }, nil + for { + select { + case <-ctx.Done(): + cancel() + s.log.Debug().Msg("Failed to obtain ePBS beacon block proposal before timeout") + return nil, errors.New("failed to obtain ePBS beacon block proposal before timeout") + case proposal := <-proposalCh: + if opts.IncludePayload != nil && *opts.IncludePayload && !proposal.ExecutionPayloadIncluded { + s.log.Warn().Msg("Discarding ePBS proposal without requested execution payload") + continue + } + cancel() + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: make(map[string]any), + }, nil + } } } From 1a263395457ece64c887d32426a0ef85e4016380 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 17:13:33 +0200 Subject: [PATCH 08/30] Warn when Gloas proposals ignore builder settings The Gloas path always builds locally, so a configured block auctioneer and a non-default builder boost factor have no effect once the fork is active. Neither was reported, leaving an operator to believe their relay configuration still applied. Log a warning for each on the ePBS path. --- .../standard/gloas_test.go | 35 ++++++++++++++++--- .../beaconblockproposer/standard/propose.go | 6 ++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 25f72f74..499c07ba 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + mockblockauctioneer "github.com/attestantio/go-block-relay/services/blockauctioneer/mock" consensusapi "github.com/attestantio/go-eth2-client/api" mockconsensusclient "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/go-eth2-client/spec/deneb" @@ -27,11 +28,14 @@ import ( mockaccountmanager "github.com/attestantio/vouch/services/accountmanager/mock" "github.com/attestantio/vouch/services/beaconblockproposer" "github.com/attestantio/vouch/services/beaconblockproposer/standard" + "github.com/attestantio/vouch/services/cache" + mockcache "github.com/attestantio/vouch/services/cache/mock" "github.com/attestantio/vouch/services/chaintime" nullmetrics "github.com/attestantio/vouch/services/metrics/null" "github.com/attestantio/vouch/services/signer" mocksigner "github.com/attestantio/vouch/services/signer/mock" "github.com/attestantio/vouch/services/submitter" + "github.com/attestantio/vouch/testing/logger" "github.com/google/uuid" "github.com/rs/zerolog" "github.com/stretchr/testify/require" @@ -45,6 +49,8 @@ func TestProposeGloas(t *testing.T) { tests := []struct { name string executionPayloadIncluded bool + blockAuctioneer bool + builderBoostFactor uint64 envelopeRootMismatch bool envelopeSignerErr error err string @@ -52,6 +58,12 @@ func TestProposeGloas(t *testing.T) { { name: "PayloadIncluded", executionPayloadIncluded: true, + builderBoostFactor: 100, + }, + { + name: "ConfiguredAuctioneer", + executionPayloadIncluded: true, + blockAuctioneer: true, }, { name: "PayloadExcluded", @@ -74,6 +86,7 @@ func TestProposeGloas(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + capture := logger.NewLogCapture() proposalClient, err := mockconsensusclient.New(ctx) require.NoError(t, err) proposalClient.ProposalFunc = func(context.Context, *consensusapi.ProposalOpts) (*consensusapi.Response[*consensusapi.VersionedProposal], error) { @@ -113,8 +126,8 @@ func TestProposeGloas(t *testing.T) { envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature, err: test.envelopeSignerErr} envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} - service, err := standard.New(ctx, - standard.WithLogLevel(zerolog.Disabled), + params := []standard.Parameter{ + standard.WithLogLevel(zerolog.TraceLevel), standard.WithMonitor(nullmetrics.New()), standard.WithProposalDataProvider(proposalClient), standard.WithChainTime(&forkChainTime{}), @@ -125,8 +138,16 @@ func TestProposeGloas(t *testing.T) { standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), standard.WithBlobSidecarSigner(signer), - standard.WithBuilderBoostFactor(100), - ) + standard.WithBuilderBoostFactor(test.builderBoostFactor), + } + if test.blockAuctioneer { + cacheService := mockcache.New(map[phase0.Root]phase0.Slot{}) + params = append(params, + standard.WithBlockAuctioneer(mockblockauctioneer.New()), + standard.WithExecutionChainHeadProvider(cacheService.(cache.ExecutionChainHeadProvider)), + ) + } + service, err := standard.New(ctx, params...) require.NoError(t, err) duty := beaconblockproposer.NewDuty(1, 2) @@ -134,6 +155,12 @@ func TestProposeGloas(t *testing.T) { duty.SetRandaoReveal(phase0.BLSSignature{0x02}) err = service.Propose(ctx, duty) + if test.builderBoostFactor != 0 { + capture.AssertHasEntry(t, "Ignoring non-default builder boost factor on Gloas proposal path") + } + if test.blockAuctioneer { + capture.AssertHasEntry(t, "Ignoring configured block auctioneer on Gloas proposal path") + } require.NotNil(t, epbsOpts) require.NotNil(t, epbsOpts.IncludePayload) require.True(t, *epbsOpts.IncludePayload) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 66ff1643..40f12f78 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -226,6 +226,12 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if s.executionPayloadEnvelopeSubmitter == nil { return errors.New("no execution payload envelope submitter available") } + if s.blockAuctioneer != nil { + s.log.Warn().Msg("Ignoring configured block auctioneer on Gloas proposal path") + } + if s.builderBoostFactor != 0 { + s.log.Warn().Msg("Ignoring non-default builder boost factor on Gloas proposal path") + } includePayload := true // Force local building and include its payload so any beacon node can publish it. From 80c050735f1a42fa39f38cd8db722c022a11488f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 17:21:38 +0200 Subject: [PATCH 09/30] Validate the proposer index of a Gloas proposal confirmEPBSProposalData checked the slot of the returned proposal but not its proposer index, so a proposal built for a different validator would be signed against this duty's account. Compare the proposal's proposer index against the duty's validator index and reject a mismatch, as the slot check already does. --- .../beaconblockproposer/standard/gloas_test.go | 17 ++++++++++++++++- .../beaconblockproposer/standard/propose.go | 9 ++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 499c07ba..d7dc8069 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -51,6 +51,7 @@ func TestProposeGloas(t *testing.T) { executionPayloadIncluded bool blockAuctioneer bool builderBoostFactor uint64 + proposerIndexMismatch bool envelopeRootMismatch bool envelopeSignerErr error err string @@ -65,6 +66,12 @@ func TestProposeGloas(t *testing.T) { executionPayloadIncluded: true, blockAuctioneer: true, }, + { + name: "MismatchedProposerIndex", + executionPayloadIncluded: true, + proposerIndexMismatch: true, + err: "failed to propose block: ePBS proposal data for incorrect proposer index", + }, { name: "PayloadExcluded", executionPayloadIncluded: false, @@ -103,6 +110,9 @@ func TestProposeGloas(t *testing.T) { response, err := responseClient.EPBSProposal(ctx, &responseOpts) if err == nil && response.Data.ExecutionPayloadIncluded { + if test.proposerIndexMismatch { + response.Data.GloasContents.Block.ProposerIndex++ + } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() @@ -150,7 +160,7 @@ func TestProposeGloas(t *testing.T) { service, err := standard.New(ctx, params...) require.NoError(t, err) - duty := beaconblockproposer.NewDuty(1, 2) + duty := beaconblockproposer.NewDuty(1, 0) duty.SetAccount(&testAccount{}) duty.SetRandaoReveal(phase0.BLSSignature{0x02}) @@ -175,6 +185,11 @@ func TestProposeGloas(t *testing.T) { require.Zero(t, envelopeSigner.calls) require.Nil(t, envelopeSubmitter.opts) } + if test.proposerIndexMismatch { + require.Zero(t, blockSigner.calls) + require.Zero(t, envelopeSigner.calls) + require.Nil(t, envelopeSubmitter.opts) + } if test.envelopeSignerErr != nil { require.Equal(t, 1, blockSigner.calls) require.Equal(t, 1, envelopeSigner.calls) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 40f12f78..1d06e1b7 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -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 @@ -338,6 +338,13 @@ func (*Service) confirmEPBSProposalData(_ context.Context, if proposalSlot != duty.Slot() { return errors.New("ePBS proposal data for incorrect slot") } + proposalProposerIndex, err := proposal.ProposerIndex() + if err != nil { + return errors.Wrap(err, "failed to obtain ePBS proposal proposer index") + } + if proposalProposerIndex != duty.ValidatorIndex() { + return errors.New("ePBS proposal data for incorrect proposer index") + } return nil } From 1334b83fb475984894df46be25a02f7ccaff63d8 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 17:53:35 +0200 Subject: [PATCH 10/30] Warn when the beacon builder domain is unavailable --- services/signer/standard/service.go | 2 ++ .../standard/signexecutionpayloadenvelope.go | 2 +- ...ignexecutionpayloadenvelope_internal_test.go | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/services/signer/standard/service.go b/services/signer/standard/service.go index 844ef674..c841fbc0 100644 --- a/services/signer/standard/service.go +++ b/services/signer/standard/service.go @@ -130,6 +130,8 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { var beaconBuilderDomainType *phase0.DomainType if tmp, err := domainType(spec, "DOMAIN_BEACON_BUILDER"); err == nil { beaconBuilderDomainType = &tmp + } else { + log.Warn().Err(err).Msg("DOMAIN_BEACON_BUILDER unavailable in spec; execution payload envelope signing unavailable") } s := &Service{ diff --git a/services/signer/standard/signexecutionpayloadenvelope.go b/services/signer/standard/signexecutionpayloadenvelope.go index 399e9476..46f98699 100644 --- a/services/signer/standard/signexecutionpayloadenvelope.go +++ b/services/signer/standard/signexecutionpayloadenvelope.go @@ -39,7 +39,7 @@ func (s *Service) SignExecutionPayloadEnvelope(ctx context.Context, return phase0.BLSSignature{}, errors.New("no execution payload envelope supplied") } if s.beaconBuilderDomainType == nil { - return phase0.BLSSignature{}, errors.New("no beacon builder domain type available; cannot sign") + return phase0.BLSSignature{}, errors.New("DOMAIN_BEACON_BUILDER unavailable in beacon node spec; cannot sign execution payload envelope") } root, err := envelope.HashTreeRoot() diff --git a/services/signer/standard/signexecutionpayloadenvelope_internal_test.go b/services/signer/standard/signexecutionpayloadenvelope_internal_test.go index 1b4e2228..7f87cee6 100644 --- a/services/signer/standard/signexecutionpayloadenvelope_internal_test.go +++ b/services/signer/standard/signexecutionpayloadenvelope_internal_test.go @@ -23,6 +23,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" nullmetrics "github.com/attestantio/vouch/services/metrics/null" + "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) @@ -85,10 +86,24 @@ func TestSignExecutionPayloadEnvelopeUnavailableBeforeGloas(t *testing.T) { require.NotEqual(t, phase0.BLSSignature{}, signature) _, err = service.SignExecutionPayloadEnvelope(ctx, account, 32, &gloas.ExecutionPayloadEnvelope{}) - require.EqualError(t, err, "no beacon builder domain type available; cannot sign") + require.EqualError(t, err, "DOMAIN_BEACON_BUILDER unavailable in beacon node spec; cannot sign execution payload envelope") require.Equal(t, 1, account.signCount) } +func TestNewWarnsWhenBeaconBuilderDomainUnavailable(t *testing.T) { + capture := logger.NewLogCapture() + + _, err := New(context.Background(), + WithLogLevel(zerolog.WarnLevel), + WithMonitor(nullmetrics.New()), + WithClientMonitor(nullmetrics.New()), + WithSpecProvider(&preGloasSpecProvider{}), + WithDomainProvider(&recordingDomainProvider{}), + ) + require.NoError(t, err) + capture.AssertHasEntry(t, "DOMAIN_BEACON_BUILDER unavailable in spec; execution payload envelope signing unavailable") +} + type preGloasSpecProvider struct{} func (*preGloasSpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map[string]any], error) { From 50a0aedd4905482e2cfcfebffb772ea24c6a10b3 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 18:17:23 +0200 Subject: [PATCH 11/30] Resolve the Gloas fork epoch when proposing The service read GLOAS_FORK_EPOCH once at construction. A beacon node that has not yet scheduled the fork reports it as far future, and vouch retained that value for the lifetime of the process, so a client started before the fork was scheduled would never take the ePBS path however long it ran. Read the epoch from chainTime at each proposal instead, and drop the cached field. --- .../beaconblockproposer/standard/gloas_test.go | 17 ++++++++++++++++- .../beaconblockproposer/standard/propose.go | 2 +- .../beaconblockproposer/standard/service.go | 3 --- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index d7dc8069..6b9eceb7 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -54,6 +54,9 @@ func TestProposeGloas(t *testing.T) { proposerIndexMismatch bool envelopeRootMismatch bool envelopeSignerErr error + forkEpochAtConstruction phase0.Epoch + forkEpochAtUse phase0.Epoch + updateForkEpochAtUse bool err string }{ { @@ -61,6 +64,13 @@ func TestProposeGloas(t *testing.T) { executionPayloadIncluded: true, builderBoostFactor: 100, }, + { + name: "ForkEpochAvailableAfterConstruction", + executionPayloadIncluded: true, + forkEpochAtConstruction: phase0.Epoch(^uint64(0)), + forkEpochAtUse: 0, + updateForkEpochAtUse: true, + }, { name: "ConfiguredAuctioneer", executionPayloadIncluded: true, @@ -136,11 +146,13 @@ func TestProposeGloas(t *testing.T) { envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature, err: test.envelopeSignerErr} envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + chainTime := &forkChainTime{gloasForkEpoch: test.forkEpochAtConstruction} + params := []standard.Parameter{ standard.WithLogLevel(zerolog.TraceLevel), standard.WithMonitor(nullmetrics.New()), standard.WithProposalDataProvider(proposalClient), - standard.WithChainTime(&forkChainTime{}), + standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), standard.WithProposalSubmitter(proposalSubmitter), standard.WithRANDAORevealSigner(signer), @@ -159,6 +171,9 @@ func TestProposeGloas(t *testing.T) { } service, err := standard.New(ctx, params...) require.NoError(t, err) + if test.updateForkEpochAtUse { + chainTime.gloasForkEpoch = test.forkEpochAtUse + } duty := beaconblockproposer.NewDuty(1, 0) duty.SetAccount(&testAccount{}) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 1d06e1b7..77cda58c 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -141,7 +141,7 @@ func (s *Service) proposeBlock(ctx context.Context, duty *beaconblockproposer.Duty, graffiti [32]byte, ) error { - if s.chainTime.SlotToEpoch(duty.Slot()) >= s.gloasForkEpoch { + if s.chainTime.SlotToEpoch(duty.Slot()) >= s.chainTime.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") { return s.proposeEPBSBlock(ctx, duty, graffiti) } diff --git a/services/beaconblockproposer/standard/service.go b/services/beaconblockproposer/standard/service.go index 27b03d60..5bc13eba 100644 --- a/services/beaconblockproposer/standard/service.go +++ b/services/beaconblockproposer/standard/service.go @@ -52,7 +52,6 @@ type Service struct { blockAuctioneer blockauctioneer.BlockAuctioneer unblindFromAllRelays bool builderBoostFactor uint64 - gloasForkEpoch phase0.Epoch } // New creates a new beacon block proposer. @@ -72,7 +71,6 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { return nil, errors.New("failed to register metrics") } - gloasForkEpoch := parameters.chainTime.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") s := &Service{ log: log, chainTime: parameters.chainTime, @@ -89,7 +87,6 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { blobSidecarSigner: parameters.blobSidecarSigner, unblindFromAllRelays: parameters.unblindFromAllRelays, builderBoostFactor: parameters.builderBoostFactor, - gloasForkEpoch: gloasForkEpoch, } return s, nil From 3c81e95bc2fdcc84e1e7e1d51ee077d946488481 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Wed, 12 Aug 2026 18:34:34 +0200 Subject: [PATCH 12/30] Reuse Gloas body root for block signing --- .../standard/gloas_test.go | 18 +++++++++++++++- .../beaconblockproposer/standard/propose.go | 21 ++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 6b9eceb7..4f5381f0 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -225,6 +225,20 @@ func TestProposeGloas(t *testing.T) { require.NotEmpty(t, responseProposal.GloasContents.Blobs) require.Equal(t, responseProposal.GloasContents.KZGProofs, envelopeSubmitter.opts.KZGProofs) require.Equal(t, responseProposal.GloasContents.Blobs, envelopeSubmitter.opts.Blobs) + directBlockRoot, err := responseProposal.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + bodyRoot, err := responseProposal.GloasContents.Block.Body.HashTreeRoot() + require.NoError(t, err) + headerRoot, err := (&phase0.BeaconBlockHeader{ + Slot: responseProposal.GloasContents.Block.Slot, + ProposerIndex: responseProposal.GloasContents.Block.ProposerIndex, + ParentRoot: responseProposal.GloasContents.Block.ParentRoot, + StateRoot: responseProposal.GloasContents.Block.StateRoot, + BodyRoot: bodyRoot, + }).HashTreeRoot() + require.NoError(t, err) + require.Equal(t, directBlockRoot, headerRoot) + require.Equal(t, phase0.Root(bodyRoot), blockSigner.bodyRoot) } }) } @@ -283,6 +297,7 @@ var _ submitter.ProposalSubmitter = (*capturingProposalSubmitter)(nil) type capturingBeaconBlockSigner struct { signature phase0.BLSSignature + bodyRoot phase0.Root calls int } @@ -330,9 +345,10 @@ func (s *capturingBeaconBlockSigner) SignBeaconBlockProposal( _ phase0.ValidatorIndex, _ phase0.Root, _ phase0.Root, - _ phase0.Root, + bodyRoot phase0.Root, ) (phase0.BLSSignature, error) { s.calls++ + s.bodyRoot = bodyRoot return s.signature, nil } diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 77cda58c..214c8a0b 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -260,7 +260,18 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if err != nil { return errors.Wrap(err, "failed to obtain execution payload envelope") } - blockRoot, err := proposal.GloasContents.Block.HashTreeRoot() + bodyRoot, err := proposal.BodyRoot() + if err != nil { + return errors.Wrap(err, "failed to calculate hash tree root of ePBS block body") + } + block := proposal.GloasContents.Block + blockRoot, err := (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: bodyRoot, + }).HashTreeRoot() if err != nil { return errors.Wrap(err, "failed to calculate hash tree root of ePBS block") } @@ -268,7 +279,7 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return errors.New("ePBS execution payload envelope is for incorrect block") } - signedProposal, err := s.signEPBSProposalData(ctx, proposal, duty) + signedProposal, err := s.signEPBSProposalData(ctx, proposal, duty, bodyRoot) if err != nil { return err } @@ -485,6 +496,7 @@ func (s *Service) signProposalData(ctx context.Context, func (s *Service) signEPBSProposalData(ctx context.Context, proposal *api.VersionedEPBSProposal, duty *beaconblockproposer.Duty, + bodyRoot phase0.Root, ) ( *api.VersionedSignedProposal, error, @@ -493,11 +505,6 @@ func (s *Service) signEPBSProposalData(ctx context.Context, return nil, errors.New("unhandled ePBS proposal version") } - bodyRoot, err := proposal.BodyRoot() - if err != nil { - return nil, errors.Wrap(err, "failed to calculate hash tree root of ePBS block body proposal") - } - parentRoot, err := proposal.ParentRoot() if err != nil { return nil, errors.Wrap(err, "failed to obtain parent root of ePBS block proposal") From 326139bd0b8e9496bd689a2737c7b653772ecffb Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 07:11:35 +0200 Subject: [PATCH 13/30] Harden Gloas ePBS proposal handling Several defects on the Gloas path shared a root cause: values returned by a beacon node were used without first checking their shape. - Sign the block and the execution payload envelope concurrently and wait for both before submitting either, so the path keeps failing closed while no longer paying for the two signatures in sequence. - Guard the nil cases in both proposal strategies: an absent response, absent GloasContents when the payload is reported as included, and an absent block, body or execution payload bid. In the best strategy these were reachable dereferences that would panic the goroutine handling that node's response. - Apply the zero fee recipient check to the first strategy, which until now only the best strategy performed. - Require the execution payload envelope signer and submitter in the proposer and in both submitter implementations, so a misconfiguration is caught at startup rather than at the first Gloas proposal. Adds a table driven regression test covering each arm of the ePBS proposal path. --- .../standard/gloas_test.go | 334 ++++++++++++++++++ .../standard/parameters.go | 8 +- .../standard/prepare_test.go | 4 +- .../beaconblockproposer/standard/propose.go | 25 +- .../standard/propose_test.go | 4 +- .../standard/service_test.go | 42 ++- services/submitter/immediate/parameters.go | 5 +- services/submitter/immediate/service_test.go | 57 ++- services/submitter/multinode/parameters.go | 5 +- services/submitter/multinode/service_test.go | 64 +++- .../submitaggregateattestations_test.go | 12 +- .../multinode/submitattestations_test.go | 12 +- ...submitbeaconcommitteesubscriptions_test.go | 12 +- .../multinode/submitproposal_test.go | 12 +- .../submitproposalpreparations_test.go | 12 +- .../submitsynccommitteecontributions_test.go | 12 +- .../submitsynccommitteemessages_test.go | 12 +- .../submitsynccommitteesubscriptions_test.go | 12 +- .../best/beaconblockproposal.go | 25 ++ .../best/epbsproposal_test.go | 151 ++++++++ .../first/epbsproposal_test.go | 147 +++++++- .../beaconblockproposal/first/service.go | 30 +- 22 files changed, 914 insertions(+), 83 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 4f5381f0..11efab64 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -244,6 +244,270 @@ func TestProposeGloas(t *testing.T) { } } +func TestProposeGloasStartsBothSignaturesBeforePublication(t *testing.T) { + ctx := context.Background() + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + includePayload := true + responseOpts := *opts + responseOpts.IncludePayload = &includePayload + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err != nil { + return nil, err + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + + return response, nil + } + + blockSigningStarted := make(chan struct{}, 1) + envelopeSigningStarted := make(chan struct{}, 1) + releaseSignatures := make(chan struct{}) + defer func() { + select { + case <-releaseSignatures: + default: + close(releaseSignatures) + } + }() + proposalSubmitter := &capturingProposalSubmitter{} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + blockSigner := &capturingBeaconBlockSigner{ + signature: phase0.BLSSignature{0x01}, + started: blockSigningStarted, + release: releaseSignatures, + } + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{ + signature: phase0.BLSSignature{0x03}, + started: envelopeSigningStarted, + release: releaseSignatures, + } + signer := mocksigner.New() + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + result := make(chan error, 1) + go func() { + result <- service.Propose(ctx, duty) + }() + + select { + case <-blockSigningStarted: + case <-time.After(time.Second): + t.Fatal("block signing did not start") + } + select { + case <-envelopeSigningStarted: + case <-time.After(time.Second): + t.Fatal("execution payload envelope signing did not start") + } + require.Nil(t, proposalSubmitter.proposal) + require.Nil(t, envelopeSubmitter.opts) + + close(releaseSignatures) + require.NoError(t, <-result) + require.NotNil(t, proposalSubmitter.proposal) + require.NotNil(t, envelopeSubmitter.opts) +} + +func TestProposeGloasCancelsPeerSigningAfterFailure(t *testing.T) { + ctx := context.Background() + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + includePayload := true + responseOpts := *opts + responseOpts.IncludePayload = &includePayload + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err != nil { + return nil, err + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + + return response, nil + } + + blockSigningStarted := make(chan struct{}, 1) + envelopeSigningStarted := make(chan struct{}, 1) + envelopeSigningCancelled := make(chan struct{}, 1) + releaseBlockSigning := make(chan struct{}) + proposalSubmitter := &capturingProposalSubmitter{} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + blockSigner := &capturingBeaconBlockSigner{ + err: errors.New("block signing failed"), + started: blockSigningStarted, + release: releaseBlockSigning, + } + envelopeSigner := &contextBlockingExecutionPayloadEnvelopeSigner{ + started: envelopeSigningStarted, + cancelled: envelopeSigningCancelled, + } + signer := mocksigner.New() + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + result := make(chan error, 1) + go func() { + result <- service.Propose(ctx, duty) + }() + + select { + case <-blockSigningStarted: + case <-time.After(time.Second): + t.Fatal("block signing did not start") + } + select { + case <-envelopeSigningStarted: + case <-time.After(time.Second): + t.Fatal("execution payload envelope signing did not start") + } + close(releaseBlockSigning) + + select { + case err := <-result: + require.EqualError(t, err, "failed to propose block: failed to sign ePBS beacon block proposal: block signing failed") + case <-time.After(time.Second): + t.Fatal("proposal did not return after block signing failure") + } + select { + case <-envelopeSigningCancelled: + case <-time.After(time.Second): + t.Fatal("execution payload envelope signing was not cancelled") + } + require.Nil(t, proposalSubmitter.proposal) + require.Nil(t, envelopeSubmitter.opts) +} + +func TestProposeGloasCancelsBlockedBlockSigningAfterEnvelopeFailure(t *testing.T) { + ctx := context.Background() + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + includePayload := true + responseOpts := *opts + responseOpts.IncludePayload = &includePayload + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err != nil { + return nil, err + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + + return response, nil + } + + blockSigningStarted := make(chan struct{}, 1) + blockSigningCancelled := make(chan struct{}, 1) + envelopeSigningStarted := make(chan struct{}, 1) + proposalSubmitter := &capturingProposalSubmitter{} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + signingErr := errors.New("signing failed") + blockSigner := &contextBlockingBeaconBlockSigner{ + started: blockSigningStarted, + cancelled: blockSigningCancelled, + err: signingErr, + } + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{ + err: signingErr, + started: envelopeSigningStarted, + } + signer := mocksigner.New() + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + result := make(chan error, 1) + go func() { + result <- service.Propose(ctx, duty) + }() + + select { + case <-blockSigningStarted: + case <-time.After(time.Second): + t.Fatal("block signing did not start") + } + select { + case <-envelopeSigningStarted: + case <-time.After(time.Second): + t.Fatal("execution payload envelope signing did not start") + } + select { + case err := <-result: + require.EqualError(t, err, "failed to propose block: failed to sign execution payload envelope: signing failed") + case <-time.After(time.Second): + t.Fatal("proposal did not return after execution payload envelope signing failure") + } + select { + case <-blockSigningCancelled: + case <-time.After(time.Second): + t.Fatal("block signing was not cancelled") + } + require.Nil(t, proposalSubmitter.proposal) + require.Nil(t, envelopeSubmitter.opts) +} + func TestProposePreGloas(t *testing.T) { ctx := context.Background() @@ -266,8 +530,10 @@ func TestProposePreGloas(t *testing.T) { standard.WithChainTime(&forkChainTime{gloasForkEpoch: 1}), standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), standard.WithProposalSubmitter(proposalSubmitter), + standard.WithExecutionPayloadEnvelopeSubmitter(responseClient), standard.WithRANDAORevealSigner(signer), standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithBlobSidecarSigner(signer), ) require.NoError(t, err) @@ -299,6 +565,9 @@ type capturingBeaconBlockSigner struct { signature phase0.BLSSignature bodyRoot phase0.Root calls int + err error + started chan<- struct{} + release <-chan struct{} } type capturingExecutionPayloadEnvelopeSigner struct { @@ -306,6 +575,8 @@ type capturingExecutionPayloadEnvelopeSigner struct { envelope *gloas.ExecutionPayloadEnvelope calls int err error + started chan<- struct{} + release <-chan struct{} } func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( @@ -316,6 +587,12 @@ func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( ) (phase0.BLSSignature, error) { s.calls++ s.envelope = envelope + if s.started != nil { + s.started <- struct{}{} + } + if s.release != nil { + <-s.release + } if s.err != nil { return phase0.BLSSignature{}, s.err } @@ -324,6 +601,54 @@ func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( var _ signer.ExecutionPayloadEnvelopeSigner = (*capturingExecutionPayloadEnvelopeSigner)(nil) +type contextBlockingExecutionPayloadEnvelopeSigner struct { + started chan<- struct{} + cancelled chan<- struct{} +} + +func (s *contextBlockingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( + ctx context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + _ *gloas.ExecutionPayloadEnvelope, +) (phase0.BLSSignature, error) { + s.started <- struct{}{} + <-ctx.Done() + s.cancelled <- struct{}{} + + return phase0.BLSSignature{}, ctx.Err() +} + +var _ signer.ExecutionPayloadEnvelopeSigner = (*contextBlockingExecutionPayloadEnvelopeSigner)(nil) + +type contextBlockingBeaconBlockSigner struct { + started chan<- struct{} + cancelled chan<- struct{} + err error +} + +func (s *contextBlockingBeaconBlockSigner) SignBeaconBlockProposal( + ctx context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + _ phase0.ValidatorIndex, + _ phase0.Root, + _ phase0.Root, + _ phase0.Root, +) (phase0.BLSSignature, error) { + s.started <- struct{}{} + <-ctx.Done() + s.cancelled <- struct{}{} + + if s.err != nil { + return phase0.BLSSignature{}, s.err + } + + return phase0.BLSSignature{}, ctx.Err() +} + +var _ signer.BeaconBlockSigner = (*contextBlockingBeaconBlockSigner)(nil) + type capturingExecutionPayloadEnvelopeSubmitter struct { opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts } @@ -349,6 +674,15 @@ func (s *capturingBeaconBlockSigner) SignBeaconBlockProposal( ) (phase0.BLSSignature, error) { s.calls++ s.bodyRoot = bodyRoot + if s.started != nil { + s.started <- struct{}{} + } + if s.release != nil { + <-s.release + } + if s.err != nil { + return phase0.BLSSignature{}, s.err + } return s.signature, nil } diff --git a/services/beaconblockproposer/standard/parameters.go b/services/beaconblockproposer/standard/parameters.go index 9a389ccf..8507d973 100644 --- a/services/beaconblockproposer/standard/parameters.go +++ b/services/beaconblockproposer/standard/parameters.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2022 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 @@ -209,6 +209,12 @@ func parseAndCheckParameters(params ...Parameter) (*parameters, error) { if parameters.beaconBlockSigner == nil { return nil, errors.New("no beacon block signer specified") } + if parameters.executionPayloadEnvelopeSigner == nil { + return nil, errors.New("no execution payload envelope signer specified") + } + if parameters.executionPayloadEnvelopeSubmitter == nil { + return nil, errors.New("no execution payload envelope submitter specified") + } if parameters.blobSidecarSigner == nil { return nil, errors.New("no blob sidecar signer specified") } diff --git a/services/beaconblockproposer/standard/prepare_test.go b/services/beaconblockproposer/standard/prepare_test.go index 2f224551..f4616ecb 100644 --- a/services/beaconblockproposer/standard/prepare_test.go +++ b/services/beaconblockproposer/standard/prepare_test.go @@ -1,4 +1,4 @@ -// Copyright © 2021, 2022 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 @@ -83,9 +83,11 @@ func TestPrepare(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithGraffitiProvider(graffitiProvider), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithBlobSidecarSigner(signer), standard.WithBlockAuctioneer(blockAuctioneer), standard.WithExecutionChainHeadProvider(cacheService.(cache.ExecutionChainHeadProvider)), diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 214c8a0b..daf13978 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -44,6 +44,7 @@ import ( "github.com/pkg/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" ) @@ -279,14 +280,26 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return errors.New("ePBS execution payload envelope is for incorrect block") } - signedProposal, err := s.signEPBSProposalData(ctx, proposal, duty, bodyRoot) - if err != nil { + var signedProposal *api.VersionedSignedProposal + var signature phase0.BLSSignature + signingGroup, signingCtx := errgroup.WithContext(ctx) + signingGroup.Go(func() error { + var err error + signedProposal, err = s.signEPBSProposalData(signingCtx, proposal, duty, bodyRoot) + return err - } + }) + signingGroup.Go(func() error { + var err error + signature, err = s.executionPayloadEnvelopeSigner.SignExecutionPayloadEnvelope(signingCtx, duty.Account(), duty.Slot(), envelope) + if err != nil { + return errors.Wrap(err, "failed to sign execution payload envelope") + } - signature, err := s.executionPayloadEnvelopeSigner.SignExecutionPayloadEnvelope(ctx, duty.Account(), duty.Slot(), envelope) - if err != nil { - return errors.Wrap(err, "failed to sign execution payload envelope") + return nil + }) + if err := signingGroup.Wait(); err != nil { + return err } kzgProofs, err := proposal.KZGProofs() if err != nil { diff --git a/services/beaconblockproposer/standard/propose_test.go b/services/beaconblockproposer/standard/propose_test.go index b0eae759..65a8cea0 100644 --- a/services/beaconblockproposer/standard/propose_test.go +++ b/services/beaconblockproposer/standard/propose_test.go @@ -1,4 +1,4 @@ -// Copyright © 2021 - 2023 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 @@ -121,9 +121,11 @@ func TestPropose(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithGraffitiProvider(graffitiProvider), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithBlobSidecarSigner(signer), standard.WithBlockAuctioneer(blockAuctioneer), standard.WithExecutionChainHeadProvider(cacheService.(cache.ExecutionChainHeadProvider)), diff --git a/services/beaconblockproposer/standard/service_test.go b/services/beaconblockproposer/standard/service_test.go index 5f9e5a71..05d26afe 100644 --- a/services/beaconblockproposer/standard/service_test.go +++ b/services/beaconblockproposer/standard/service_test.go @@ -1,4 +1,4 @@ -// Copyright © 2021, 2022 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 @@ -170,11 +170,45 @@ func TestService(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), }, err: "problem with parameters: no blob sidecar signer specified", }, + { + name: "ExecutionPayloadEnvelopeSignerMissing", + params: []standard.Parameter{ + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(consensusClient), + standard.WithChainTime(chainTime), + standard.WithValidatingAccountsProvider(validatingAccountsProvider), + standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(signer), + standard.WithBlobSidecarSigner(signer), + }, + err: "problem with parameters: no execution payload envelope signer specified", + }, + { + name: "ExecutionPayloadEnvelopeSubmitterMissing", + params: []standard.Parameter{ + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(consensusClient), + standard.WithChainTime(chainTime), + standard.WithValidatingAccountsProvider(validatingAccountsProvider), + standard.WithProposalSubmitter(consensusClient), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), + standard.WithBlobSidecarSigner(signer), + }, + err: "problem with parameters: no execution payload envelope submitter specified", + }, { name: "GoodWithOptionals", params: []standard.Parameter{ @@ -184,8 +218,10 @@ func TestService(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithGraffitiProvider(graffitiProvider), standard.WithBlobSidecarSigner(signer), }, @@ -199,8 +235,10 @@ func TestService(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithBlobSidecarSigner(signer), standard.WithBlockAuctioneer(blockAuctioneer), }, @@ -215,8 +253,10 @@ func TestService(t *testing.T) { standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(validatingAccountsProvider), standard.WithProposalSubmitter(consensusClient), + standard.WithExecutionPayloadEnvelopeSubmitter(consensusClient), standard.WithRANDAORevealSigner(signer), standard.WithBeaconBlockSigner(signer), + standard.WithExecutionPayloadEnvelopeSigner(signer), standard.WithBlobSidecarSigner(signer), standard.WithBlockAuctioneer(blockAuctioneer), standard.WithExecutionChainHeadProvider(cacheService.(cache.ExecutionChainHeadProvider)), diff --git a/services/submitter/immediate/parameters.go b/services/submitter/immediate/parameters.go index 43f228e3..dd3f279b 100644 --- a/services/submitter/immediate/parameters.go +++ b/services/submitter/immediate/parameters.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 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 @@ -142,6 +142,9 @@ func parseAndCheckParameters(params ...Parameter) (*parameters, error) { if parameters.proposalSubmitter == nil { return nil, errors.New("no proposal submitter specified") } + if parameters.executionPayloadEnvelopeSubmitter == nil { + return nil, errors.New("no execution payload envelope submitter specified") + } if parameters.attestationsSubmitter == nil { return nil, errors.New("no attestations submitter specified") } diff --git a/services/submitter/immediate/service_test.go b/services/submitter/immediate/service_test.go index 9fd100a0..82278447 100644 --- a/services/submitter/immediate/service_test.go +++ b/services/submitter/immediate/service_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020 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 @@ -15,11 +15,12 @@ package immediate_test import ( "context" - "github.com/attestantio/go-eth2-client/spec" "testing" "github.com/attestantio/go-eth2-client/api" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + 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/vouch/mock" "github.com/attestantio/vouch/services/submitter" @@ -29,6 +30,7 @@ import ( ) func TestService(t *testing.T) { + ctx := context.Background() attestationsSubmitter := mock.NewAttestationsSubmitter() proposalSubmitter := mock.NewProposalSubmitter() beaconCommitteeSubscriptionSubmitter := mock.NewBeaconCommitteeSubscriptionsSubmitter() @@ -37,6 +39,8 @@ func TestService(t *testing.T) { syncCommitteeMessagesSubmitter := mock.NewSyncCommitteeMessagesSubmitter() syncCommitteeSubscriptionsSubmitter := mock.NewSyncCommitteeSubscriptionsSubmitter() syncCommitteeContributionsSubmitter := mock.NewSyncCommitteeContributionsSubmitter() + executionPayloadEnvelopeSubmitter, err := mockconsensusclient.New(ctx) + require.NoError(t, err) tests := []struct { name string @@ -87,6 +91,21 @@ func TestService(t *testing.T) { }, err: "problem with parameters: no proposal submitter specified", }, + { + name: "ExecutionPayloadEnvelopeSubmitterMissing", + params: []immediate.Parameter{ + immediate.WithLogLevel(zerolog.Disabled), + immediate.WithAttestationsSubmitter(attestationsSubmitter), + immediate.WithProposalSubmitter(proposalSubmitter), + immediate.WithBeaconCommitteeSubscriptionsSubmitter(beaconCommitteeSubscriptionSubmitter), + immediate.WithAggregateAttestationsSubmitter(aggregateAttestationSubmitter), + immediate.WithProposalPreparationsSubmitter(proposalPreparationsSubmitter), + immediate.WithSyncCommitteeSubscriptionsSubmitter(syncCommitteeSubscriptionsSubmitter), + immediate.WithSyncCommitteeMessagesSubmitter(syncCommitteeMessagesSubmitter), + immediate.WithSyncCommitteeContributionsSubmitter(syncCommitteeContributionsSubmitter), + }, + err: "problem with parameters: no execution payload envelope submitter specified", + }, { name: "AttestationSubnetSubscriptionsSubmitterMissing", params: []immediate.Parameter{ @@ -189,7 +208,11 @@ func TestService(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := immediate.New(context.Background(), test.params...) + params := test.params + if test.name != "ExecutionPayloadEnvelopeSubmitterMissing" { + params = append(params, immediate.WithExecutionPayloadEnvelopeSubmitter(executionPayloadEnvelopeSubmitter)) + } + _, err := immediate.New(ctx, params...) if test.err != "" { require.EqualError(t, err, test.err) } else { @@ -199,8 +222,18 @@ func TestService(t *testing.T) { } } +func newTestService(ctx context.Context, params ...immediate.Parameter) (*immediate.Service, error) { + executionPayloadEnvelopeSubmitter, err := mockconsensusclient.New(ctx) + if err != nil { + return nil, err + } + params = append(params, immediate.WithExecutionPayloadEnvelopeSubmitter(executionPayloadEnvelopeSubmitter)) + + return immediate.New(ctx, params...) +} + func TestInterfaces(t *testing.T) { - s, err := immediate.New(context.Background(), + s, err := newTestService(context.Background(), immediate.WithLogLevel(zerolog.Disabled), immediate.WithAttestationsSubmitter(mock.NewAttestationsSubmitter()), immediate.WithProposalSubmitter(mock.NewProposalSubmitter()), @@ -293,7 +326,7 @@ func TestSubmitProposal(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -379,7 +412,7 @@ func TestSubmitAttestations(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -472,7 +505,7 @@ func TestSubmitAggregateAttestations(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -566,7 +599,7 @@ func TestSubmitProposalPreparations(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -656,7 +689,7 @@ func TestSubmitBeaconCommitteeSubscriptions(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -746,7 +779,7 @@ func TestSubmitSyncCommitteeSubscriptions(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -836,7 +869,7 @@ func TestSubmitSyncCommitteeMessages(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { @@ -926,7 +959,7 @@ func TestSubmitSyncCommitteeContributions(t *testing.T) { } for _, test := range tests { - s, err := immediate.New(context.Background(), test.params...) + s, err := newTestService(context.Background(), test.params...) require.NoError(t, err) t.Run(test.name, func(t *testing.T) { diff --git a/services/submitter/multinode/parameters.go b/services/submitter/multinode/parameters.go index 915d662a..4451c506 100644 --- a/services/submitter/multinode/parameters.go +++ b/services/submitter/multinode/parameters.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2022 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 @@ -167,6 +167,9 @@ func parseAndCheckParameters(params ...Parameter) (*parameters, error) { if len(parameters.proposalSubmitters) == 0 { return nil, errors.New("no proposal submitters specified") } + if len(parameters.executionPayloadEnvelopeSubmitters) == 0 { + return nil, errors.New("no execution payload envelope submitters specified") + } if len(parameters.attestationsSubmitters) == 0 { return nil, errors.New("no attestations submitters specified") } diff --git a/services/submitter/multinode/service_test.go b/services/submitter/multinode/service_test.go index 8a37e43e..2ad6d5a7 100644 --- a/services/submitter/multinode/service_test.go +++ b/services/submitter/multinode/service_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -19,6 +19,7 @@ import ( "time" eth2client "github.com/attestantio/go-eth2-client" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" "github.com/attestantio/vouch/mock" "github.com/attestantio/vouch/services/submitter" "github.com/attestantio/vouch/services/submitter/multinode" @@ -27,6 +28,12 @@ import ( ) func TestService(t *testing.T) { + ctx := context.Background() + executionPayloadEnvelopeSubmitter, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + executionPayloadEnvelopeSubmitters := map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "1": executionPayloadEnvelopeSubmitter, + } attestationsSubmitters := map[string]eth2client.AttestationsSubmitter{ "1": mock.NewAttestationsSubmitter(), } @@ -372,6 +379,41 @@ func TestService(t *testing.T) { }, err: "problem with parameters: no sync committee contributions submitters specified", }, + { + name: "ExecutionPayloadEnvelopeSubmittersMissing", + params: []multinode.Parameter{ + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(2 * time.Second), + multinode.WithProcessConcurrency(2), + multinode.WithProposalSubmitters(beaconBlockSubmitters), + multinode.WithAttestationsSubmitters(attestationsSubmitters), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(beaconCommitteeSubscriptionsSubmitters), + multinode.WithAggregateAttestationsSubmitters(aggregateAttestationsSubmitters), + multinode.WithProposalPreparationsSubmitters(proposalPrepartionsSubmitters), + multinode.WithSyncCommitteeMessagesSubmitters(syncCommitteeMessagesSubmitters), + multinode.WithSyncCommitteeSubscriptionsSubmitters(syncCommitteeSubscriptionsSubmitters), + multinode.WithSyncCommitteeContributionsSubmitters(syncCommitteeContributionsSubmitters), + }, + err: "problem with parameters: no execution payload envelope submitters specified", + }, + { + name: "ExecutionPayloadEnvelopeSubmittersEmpty", + params: []multinode.Parameter{ + multinode.WithLogLevel(zerolog.Disabled), + multinode.WithTimeout(2 * time.Second), + multinode.WithProcessConcurrency(2), + multinode.WithProposalSubmitters(beaconBlockSubmitters), + multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{}), + multinode.WithAttestationsSubmitters(attestationsSubmitters), + multinode.WithBeaconCommitteeSubscriptionsSubmitters(beaconCommitteeSubscriptionsSubmitters), + multinode.WithAggregateAttestationsSubmitters(aggregateAttestationsSubmitters), + multinode.WithProposalPreparationsSubmitters(proposalPrepartionsSubmitters), + multinode.WithSyncCommitteeMessagesSubmitters(syncCommitteeMessagesSubmitters), + multinode.WithSyncCommitteeSubscriptionsSubmitters(syncCommitteeSubscriptionsSubmitters), + multinode.WithSyncCommitteeContributionsSubmitters(syncCommitteeContributionsSubmitters), + }, + err: "problem with parameters: no execution payload envelope submitters specified", + }, { name: "Good", params: []multinode.Parameter{ @@ -392,7 +434,11 @@ func TestService(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := multinode.New(context.Background(), test.params...) + params := test.params + if test.name != "ExecutionPayloadEnvelopeSubmittersMissing" && test.name != "ExecutionPayloadEnvelopeSubmittersEmpty" { + params = append(params, multinode.WithExecutionPayloadEnvelopeSubmitters(executionPayloadEnvelopeSubmitters)) + } + _, err := multinode.New(ctx, params...) if test.err != "" { require.EqualError(t, err, test.err) } else { @@ -402,8 +448,20 @@ func TestService(t *testing.T) { } } +func newTestService(ctx context.Context, params ...multinode.Parameter) (*multinode.Service, error) { + executionPayloadEnvelopeSubmitter, err := mockconsensusclient.New(ctx) + if err != nil { + return nil, err + } + params = append(params, multinode.WithExecutionPayloadEnvelopeSubmitters(map[string]eth2client.ExecutionPayloadEnvelopeSubmitter{ + "1": executionPayloadEnvelopeSubmitter, + })) + + return multinode.New(ctx, params...) +} + func TestInterfaces(t *testing.T) { - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitaggregateattestations_test.go b/services/submitter/multinode/submitaggregateattestations_test.go index e1d84862..6602b93a 100644 --- a/services/submitter/multinode/submitaggregateattestations_test.go +++ b/services/submitter/multinode/submitaggregateattestations_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -33,7 +33,7 @@ import ( func TestSubmitAggregateAttestationsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -76,7 +76,7 @@ func TestSubmitAggregateAttestations(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -144,7 +144,7 @@ func TestSubmitAggregateAttestations(t *testing.T) { func TestSubmitAggregateAttestationsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -209,7 +209,7 @@ func TestSubmitAggregateAttestationsErroring(t *testing.T) { func TestSubmitAggregateAttestationsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -274,7 +274,7 @@ func TestSubmitAggregateAttestationsSleepy(t *testing.T) { func TestSubmitAggregateAttestationsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitattestations_test.go b/services/submitter/multinode/submitattestations_test.go index 8b73dec0..62523d1e 100644 --- a/services/submitter/multinode/submitattestations_test.go +++ b/services/submitter/multinode/submitattestations_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -33,7 +33,7 @@ import ( func TestSubmitAttestationsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -75,7 +75,7 @@ func TestSubmitAttestations(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -137,7 +137,7 @@ func TestSubmitAttestations(t *testing.T) { func TestSubmitAttestationsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -195,7 +195,7 @@ func TestSubmitAttestationsErroring(t *testing.T) { func TestSubmitAttestationsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -252,7 +252,7 @@ func TestSubmitAttestationsSleepy(t *testing.T) { func TestSubmitAttestationsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitbeaconcommitteesubscriptions_test.go b/services/submitter/multinode/submitbeaconcommitteesubscriptions_test.go index 4e8c1d0d..21901b5d 100644 --- a/services/submitter/multinode/submitbeaconcommitteesubscriptions_test.go +++ b/services/submitter/multinode/submitbeaconcommitteesubscriptions_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -30,7 +30,7 @@ import ( func TestSubmitBeaconCommitteeSubscriptionsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -70,7 +70,7 @@ func TestSubmitBeaconCommitteeSubscriptions(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -114,7 +114,7 @@ func TestSubmitBeaconCommitteeSubscriptions(t *testing.T) { func TestSubmitBeaconCommitteeSubscriptionsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -154,7 +154,7 @@ func TestSubmitBeaconCommitteeSubscriptionsErroring(t *testing.T) { func TestSubmitBeaconCommitteeSubscriptionsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -194,7 +194,7 @@ func TestSubmitBeaconCommitteeSubscriptionsSleepy(t *testing.T) { func TestSubmitBeaconCommitteeSubscriptionsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitproposal_test.go b/services/submitter/multinode/submitproposal_test.go index c14b8043..084f98c0 100644 --- a/services/submitter/multinode/submitproposal_test.go +++ b/services/submitter/multinode/submitproposal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2023, 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 @@ -33,7 +33,7 @@ import ( func TestSubmitProposalEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -73,7 +73,7 @@ func TestSubmitProposal(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -124,7 +124,7 @@ func TestSubmitProposal(t *testing.T) { func TestSubmitProposalErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -171,7 +171,7 @@ func TestSubmitProposalErroring(t *testing.T) { func TestSubmitProposalSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -218,7 +218,7 @@ func TestSubmitProposalSleepy(t *testing.T) { func TestSubmitProposalSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitproposalpreparations_test.go b/services/submitter/multinode/submitproposalpreparations_test.go index 0900ac4b..80920d6f 100644 --- a/services/submitter/multinode/submitproposalpreparations_test.go +++ b/services/submitter/multinode/submitproposalpreparations_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -31,7 +31,7 @@ import ( func TestSubmitProposalPreparationsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -71,7 +71,7 @@ func TestSubmitProposalPreparations(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -118,7 +118,7 @@ func TestSubmitProposalPreparations(t *testing.T) { func TestSubmitProposalPreparationsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -161,7 +161,7 @@ func TestSubmitProposalPreparationsErroring(t *testing.T) { func TestSubmitProposalPreparationsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -204,7 +204,7 @@ func TestSubmitProposalPreparationsSleepy(t *testing.T) { func TestSubmitProposalPreparationsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitsynccommitteecontributions_test.go b/services/submitter/multinode/submitsynccommitteecontributions_test.go index b9bbd149..07aff5cf 100644 --- a/services/submitter/multinode/submitsynccommitteecontributions_test.go +++ b/services/submitter/multinode/submitsynccommitteecontributions_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -30,7 +30,7 @@ import ( func TestSubmitSyncCommitteeContributionsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -70,7 +70,7 @@ func TestSubmitSyncCommitteeContributions(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -120,7 +120,7 @@ func TestSubmitSyncCommitteeContributions(t *testing.T) { func TestSubmitSyncCommitteeContributionsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -166,7 +166,7 @@ func TestSubmitSyncCommitteeContributionsErroring(t *testing.T) { func TestSubmitSyncCommitteeContributionsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -212,7 +212,7 @@ func TestSubmitSyncCommitteeContributionsSleepy(t *testing.T) { func TestSubmitSyncCommitteeContributionsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitsynccommitteemessages_test.go b/services/submitter/multinode/submitsynccommitteemessages_test.go index d2984bda..2e16a713 100644 --- a/services/submitter/multinode/submitsynccommitteemessages_test.go +++ b/services/submitter/multinode/submitsynccommitteemessages_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -30,7 +30,7 @@ import ( func TestSubmitSyncCommitteeMessagesEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -70,7 +70,7 @@ func TestSubmitSyncCommitteeMessages(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -114,7 +114,7 @@ func TestSubmitSyncCommitteeMessages(t *testing.T) { func TestSubmitSyncCommitteeMessagesErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -154,7 +154,7 @@ func TestSubmitSyncCommitteeMessagesErroring(t *testing.T) { func TestSubmitSyncCommitteeMessagesSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -194,7 +194,7 @@ func TestSubmitSyncCommitteeMessagesSleepy(t *testing.T) { func TestSubmitSyncCommitteeMessagesSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/services/submitter/multinode/submitsynccommitteesubscriptions_test.go b/services/submitter/multinode/submitsynccommitteesubscriptions_test.go index 721b1e42..97acceb6 100644 --- a/services/submitter/multinode/submitsynccommitteesubscriptions_test.go +++ b/services/submitter/multinode/submitsynccommitteesubscriptions_test.go @@ -1,4 +1,4 @@ -// Copyright © 2022 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 @@ -30,7 +30,7 @@ import ( func TestSubmitSyncCommitteeSubscriptionsEmpty(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(2*time.Second), multinode.WithProcessConcurrency(2), @@ -70,7 +70,7 @@ func TestSubmitSyncCommitteeSubscriptions(t *testing.T) { capture := logger.NewLogCapture() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.TraceLevel), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -114,7 +114,7 @@ func TestSubmitSyncCommitteeSubscriptions(t *testing.T) { func TestSubmitSyncCommitteeSubscriptionsErroring(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -154,7 +154,7 @@ func TestSubmitSyncCommitteeSubscriptionsErroring(t *testing.T) { func TestSubmitSyncCommitteeSubscriptionsSleepy(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(100*time.Millisecond), multinode.WithProcessConcurrency(2), @@ -194,7 +194,7 @@ func TestSubmitSyncCommitteeSubscriptionsSleepy(t *testing.T) { func TestSubmitSyncCommitteeSubscriptionsSleepySuccess(t *testing.T) { ctx := context.Background() - s, err := multinode.New(context.Background(), + s, err := newTestService(context.Background(), multinode.WithLogLevel(zerolog.Disabled), multinode.WithTimeout(200*time.Millisecond), multinode.WithProcessConcurrency(2), diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 5ff41730..f554ffe6 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -228,11 +228,36 @@ func (s *Service) epbsProposal(ctx context.Context, return } + if proposalResponse == nil || proposalResponse.Data == nil { + errCh <- &beaconBlockError{ + provider: name, + err: errors.New("beacon node returned no ePBS proposal"), + } + + return + } + if proposalResponse.Data != nil && proposalResponse.Data.Version == spec.DataVersionGloas { block := proposalResponse.Data.Gloas if proposalResponse.Data.ExecutionPayloadIncluded { + if proposalResponse.Data.GloasContents == nil { + errCh <- &beaconBlockError{ + provider: name, + err: errors.New("beacon node returned malformed ePBS proposal"), + } + + return + } block = proposalResponse.Data.GloasContents.Block } + if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { + errCh <- &beaconBlockError{ + provider: name, + err: errors.New("beacon node returned malformed ePBS proposal"), + } + + return + } if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { errCh <- &beaconBlockError{ provider: name, diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index d232405b..e5b45e6e 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -210,6 +210,157 @@ func TestEPBSProposalRejectsZeroFeeRecipientWithoutPayload(t *testing.T) { require.Same(t, validCandidate, response.Data) } +func TestEPBSProposalRejectsNilData(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + validCandidate := testGloasProposal(1, bellatrix.ExecutionAddress{0x01}) + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "nil": &testEPBSProposalProvider{}, + "valid": &testEPBSProposalProvider{proposal: validCandidate}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + includePayload := true + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{IncludePayload: &includePayload}) + require.NoError(t, err) + require.Same(t, validCandidate, response.Data) +} + +func TestEPBSProposalRejectsMalformedIncludedGloasProposal(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + }{ + { + name: "MissingGloasContents", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + }, + { + name: "MissingBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + }, + { + name: "MissingBody", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{}}, + }, + }, + { + name: "MissingSignedExecutionPayloadBid", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }}, + }, + }, + { + name: "MissingExecutionPayloadBidMessage", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{}, + }, + }}, + }, + }, + { + name: "CachedMissingBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + }, + { + name: "CachedMissingBody", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{}, + }, + }, + { + name: "CachedMissingSignedExecutionPayloadBid", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }, + }, + }, + { + name: "CachedMissingExecutionPayloadBidMessage", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{}, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validCandidate := testGloasProposal(1, bellatrix.ExecutionAddress{0x01}) + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "malformed": &testEPBSProposalProvider{proposal: test.proposal}, + "valid": &testEPBSProposalProvider{proposal: validCandidate}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.Same(t, validCandidate, response.Data) + }) + } +} + func testGloasProposal(value int64, feeRecipient bellatrix.ExecutionAddress) *api.VersionedEPBSProposal { return &api.VersionedEPBSProposal{ Version: spec.DataVersionGloas, diff --git a/strategies/beaconblockproposal/first/epbsproposal_test.go b/strategies/beaconblockproposal/first/epbsproposal_test.go index d8ab855d..0e91f24a 100644 --- a/strategies/beaconblockproposal/first/epbsproposal_test.go +++ b/strategies/beaconblockproposal/first/epbsproposal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2026 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 @@ -21,7 +21,10 @@ import ( "time" "github.com/attestantio/go-eth2-client/api" - mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + "github.com/attestantio/go-eth2-client/spec" + "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/beaconblockproposer" nullmetrics "github.com/attestantio/vouch/services/metrics/null" @@ -32,14 +35,12 @@ import ( func TestEPBSProposal(t *testing.T) { ctx := context.Background() - provider, err := mockconsensusclient.New(ctx) - require.NoError(t, err) service, err := first.New(ctx, first.WithLogLevel(zerolog.Disabled), first.WithClientMonitor(nullmetrics.New()), first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ - "one": provider, + "one": &epbsProposalProvider{proposal: gloasEPBSProposal(bellatrix.ExecutionAddress{0x01})}, }), first.WithTimeout(time.Second), ) @@ -98,6 +99,118 @@ func TestEPBSProposalSkipsProposalWithoutRequestedPayload(t *testing.T) { require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") } +func TestEPBSProposalSkipsZeroFeeRecipient(t *testing.T) { + ctx := context.Background() + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "zero-fee": &epbsProposalProvider{proposal: gloasEPBSProposal(bellatrix.ExecutionAddress{})}, + }), + first.WithTimeout(10*time.Millisecond), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.Nil(t, response) + require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") +} + +func TestEPBSProposalSkipsNilResponse(t *testing.T) { + ctx := context.Background() + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "nil": &epbsProposalProvider{nilResponse: true}, + }), + first.WithTimeout(10*time.Millisecond), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.Nil(t, response) + require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") +} + +func TestEPBSProposalSkipsMalformedGloasProposal(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + proposal *api.VersionedEPBSProposal + }{ + { + name: "Nil", + }, + { + name: "GloasWithoutBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + }, + }, + { + name: "GloasContentsWithoutBlock", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{}, + }, + }, + { + name: "GloasContentsNil", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + }, + }, + { + name: "BlockWithoutBody", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{}, + }, + }, + { + name: "BodyWithoutBid", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{}, + }, + }, + }, + { + name: "BidWithoutMessage", + proposal: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + Gloas: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{}, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service, err := first.New(ctx, + first.WithLogLevel(zerolog.Disabled), + first.WithClientMonitor(nullmetrics.New()), + first.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "malformed": &epbsProposalProvider{proposal: test.proposal}, + }), + first.WithTimeout(10*time.Millisecond), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.Nil(t, response) + require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") + }) + } +} + func TestEPBSProposalWaitsForProposalWithRequestedPayload(t *testing.T) { ctx := context.Background() includePayload := true @@ -122,9 +235,26 @@ func TestEPBSProposalWaitsForProposalWithRequestedPayload(t *testing.T) { require.Same(t, included, response.Data) } +func gloasEPBSProposal(feeRecipient bellatrix.ExecutionAddress) *api.VersionedEPBSProposal { + return &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{ + Block: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{FeeRecipient: feeRecipient}, + }, + }, + }, + }, + } +} + type epbsProposalProvider struct { - proposal *api.VersionedEPBSProposal - release <-chan struct{} + proposal *api.VersionedEPBSProposal + release <-chan struct{} + nilResponse bool } func (p *epbsProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { @@ -140,6 +270,9 @@ func (p *epbsProposalProvider) EPBSProposal(_ context.Context, if p.release != nil { <-p.release } + if p.nilResponse { + return nil, nil + } return &api.Response[*api.VersionedEPBSProposal]{Data: p.proposal}, nil } diff --git a/strategies/beaconblockproposal/first/service.go b/strategies/beaconblockproposal/first/service.go index a9acf5ac..f085354c 100644 --- a/strategies/beaconblockproposal/first/service.go +++ b/strategies/beaconblockproposal/first/service.go @@ -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 @@ -19,6 +19,7 @@ import ( 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/beaconblockproposer" "github.com/attestantio/vouch/services/metrics" "github.com/attestantio/vouch/util" @@ -67,6 +68,11 @@ func (s *Service) EPBSProposal(ctx context.Context, return } + if proposalResponse == nil { + log.Warn().Msg("Discarding empty ePBS proposal response") + + return + } proposal := proposalResponse.Data log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained ePBS beacon block proposal") @@ -84,10 +90,32 @@ func (s *Service) EPBSProposal(ctx context.Context, s.log.Debug().Msg("Failed to obtain ePBS beacon block proposal before timeout") return nil, errors.New("failed to obtain ePBS beacon block proposal before timeout") case proposal := <-proposalCh: + if proposal == nil { + s.log.Warn().Msg("Discarding empty ePBS proposal") + continue + } if opts.IncludePayload != nil && *opts.IncludePayload && !proposal.ExecutionPayloadIncluded { s.log.Warn().Msg("Discarding ePBS proposal without requested execution payload") continue } + if proposal.Version == spec.DataVersionGloas { + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded { + if proposal.GloasContents == nil { + s.log.Warn().Msg("Discarding malformed ePBS proposal") + continue + } + block = proposal.GloasContents.Block + } + if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { + s.log.Warn().Msg("Discarding malformed ePBS proposal") + continue + } + if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { + s.log.Warn().Msg("Discarding ePBS proposal with 0 fee recipient") + continue + } + } cancel() return &api.Response[*api.VersionedEPBSProposal]{ Data: proposal, From cc08b1a1092fcb7874c4558be0d9704a5fb23e7e Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 07:31:00 +0200 Subject: [PATCH 14/30] Update copyright years on files modified for Gloas support The attgo_current_year linter flags every file this branch modifies whose copyright header predates the current year. Bring the seventeen outstanding headers up to date as a single sweep rather than folding them into the behavioural commits, so those diffs stay reviewable. No functional change. --- clients.go | 2 +- main.go | 2 +- mock/eth2client.go | 2 +- services/beaconblockproposer/service.go | 2 +- services/beaconblockproposer/standard/service.go | 2 +- services/signer/mock/service.go | 2 +- services/signer/service.go | 2 +- services/signer/standard/service.go | 2 +- services/submitter/immediate/service.go | 2 +- services/submitter/multinode/service.go | 2 +- services/submitter/service.go | 2 +- strategies/beaconblockproposal/best/beaconblockproposal.go | 2 +- strategies/beaconblockproposal/best/beaconblockproposal_test.go | 2 +- strategies/beaconblockproposal/best/parameters.go | 2 +- strategies/beaconblockproposal/best/service.go | 2 +- strategies/beaconblockproposal/best/service_test.go | 2 +- strategies/beaconblockproposal/first/parameters.go | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/clients.go b/clients.go index 1f60afd8..924189b7 100644 --- a/clients.go +++ b/clients.go @@ -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 diff --git a/main.go b/main.go index eade2365..c9861f7a 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/mock/eth2client.go b/mock/eth2client.go index 076166fe..ce2be4d3 100644 --- a/mock/eth2client.go +++ b/mock/eth2client.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 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 diff --git a/services/beaconblockproposer/service.go b/services/beaconblockproposer/service.go index 40e1824c..2903172a 100644 --- a/services/beaconblockproposer/service.go +++ b/services/beaconblockproposer/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2022 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 diff --git a/services/beaconblockproposer/standard/service.go b/services/beaconblockproposer/standard/service.go index 5bc13eba..0c05843d 100644 --- a/services/beaconblockproposer/standard/service.go +++ b/services/beaconblockproposer/standard/service.go @@ -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 diff --git a/services/signer/mock/service.go b/services/signer/mock/service.go index eccd8921..bd6d27a3 100644 --- a/services/signer/mock/service.go +++ b/services/signer/mock/service.go @@ -1,4 +1,4 @@ -// Copyright © 2021 Attestant Limited. +// Copyright © 2021 - 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 diff --git a/services/signer/service.go b/services/signer/service.go index fde44dab..a3bd8003 100644 --- a/services/signer/service.go +++ b/services/signer/service.go @@ -1,4 +1,4 @@ -// Copyright © 2021 Attestant Limited. +// Copyright © 2021 - 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 diff --git a/services/signer/standard/service.go b/services/signer/standard/service.go index c841fbc0..b1dae56d 100644 --- a/services/signer/standard/service.go +++ b/services/signer/standard/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020 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 diff --git a/services/submitter/immediate/service.go b/services/submitter/immediate/service.go index 4f3fa989..d08175f8 100644 --- a/services/submitter/immediate/service.go +++ b/services/submitter/immediate/service.go @@ -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 diff --git a/services/submitter/multinode/service.go b/services/submitter/multinode/service.go index a1d83979..d24df56e 100644 --- a/services/submitter/multinode/service.go +++ b/services/submitter/multinode/service.go @@ -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 diff --git a/services/submitter/service.go b/services/submitter/service.go index 4ce714eb..07c5aa62 100644 --- a/services/submitter/service.go +++ b/services/submitter/service.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 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 diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index f554ffe6..307d4d58 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -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 diff --git a/strategies/beaconblockproposal/best/beaconblockproposal_test.go b/strategies/beaconblockproposal/best/beaconblockproposal_test.go index 86f5bb8a..53d3eb40 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal_test.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020, 2021 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 diff --git a/strategies/beaconblockproposal/best/parameters.go b/strategies/beaconblockproposal/best/parameters.go index 32884406..92b0d162 100644 --- a/strategies/beaconblockproposal/best/parameters.go +++ b/strategies/beaconblockproposal/best/parameters.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 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 diff --git a/strategies/beaconblockproposal/best/service.go b/strategies/beaconblockproposal/best/service.go index b264bc41..48701fe5 100644 --- a/strategies/beaconblockproposal/best/service.go +++ b/strategies/beaconblockproposal/best/service.go @@ -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 diff --git a/strategies/beaconblockproposal/best/service_test.go b/strategies/beaconblockproposal/best/service_test.go index 69e05afd..2758b15c 100644 --- a/strategies/beaconblockproposal/best/service_test.go +++ b/strategies/beaconblockproposal/best/service_test.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2022 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 diff --git a/strategies/beaconblockproposal/first/parameters.go b/strategies/beaconblockproposal/first/parameters.go index 524954ea..5e789673 100644 --- a/strategies/beaconblockproposal/first/parameters.go +++ b/strategies/beaconblockproposal/first/parameters.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2023 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 From a02aff15fd00c6d42c41ae771d34dc35ae80ed6a Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 10:09:52 +0200 Subject: [PATCH 15/30] Bump go-eth2-client for the preset-aware ePBS body root fix go-eth2-client's generated SSZ HashTreeRoot methods inline mainnet preset sizes as Go literals, so on a custom preset they compute the wrong body root even though decode succeeds. The dependency now retains the body root the transport's own preset-aware codec computed (BeaconBlockBodyRoot) and errors rather than falling back to the generated root when it is unset. --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 77706d31..504cc0fc 100644 --- a/go.mod +++ b/go.mod @@ -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.1-0.20260811144708-94db679b233c + 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 diff --git a/go.sum b/go.sum index fca1f709..4d3bdbf9 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/attestantio/go-eth2-client v0.29.0 h1:nOVPR6boXuGn5yg94pVOKcaoiO9yyja 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= From bb790d6cd9346e69cd0137efcf43ad3e8129852c Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 10:10:04 +0200 Subject: [PATCH 16/30] Derive the Gloas block root from the retained body root proposeEPBSBlock hand-composed a phase0.BeaconBlockHeader from the GloasContents.Block fields plus the signing body root to check against the execution payload envelope's BeaconBlockRoot. That duplicated the header composition go-eth2-client's own VersionedEPBSProposal.Root() now performs, so use it instead. Root() derives the block root from the same body root that is signed here, and is the derivation the beacon node client itself used to check the envelope, so this guard and the signature cannot disagree about which body root is correct. The block's own root has no preset-dependent field of its own (it merkleizes slot, proposer index, parent root, state root and the body root, exactly like phase0.BeaconBlockHeader), so the retained body root remains the only preset-sensitive quantity. Add regression coverage with fixtures where the retained body root deliberately differs from the mainnet-shaped generated root, proving the retained value -- not the generated one -- reaches the block signer, and that a proposal missing its retained root fails the duty without signing or submitting anything. --- .../standard/gloas_test.go | 155 ++++++++++++++++++ .../beaconblockproposer/standard/propose.go | 14 +- 2 files changed, 161 insertions(+), 8 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 11efab64..2270ab4a 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -244,6 +244,161 @@ func TestProposeGloas(t *testing.T) { } } +// TestProposeGloasSignsRetainedBodyRoot proves that Propose signs and submits the +// body root retained on the proposal (BeaconBlockBodyRoot), not the body root the +// generated Body.HashTreeRoot() would compute. On a custom preset those two roots +// differ, because the generated hasher inlines mainnet sizes; the mainnet fixtures +// used elsewhere in this file have the two coincide, so they cannot tell a correct +// implementation from one that silently falls back to the wrong root. This test +// makes them deliberately differ. +func TestProposeGloasSignsRetainedBodyRoot(t *testing.T) { + ctx := context.Background() + + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + + var generatedBodyRoot, retainedBodyRoot phase0.Root + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + includePayload := true + responseOpts := *opts + responseOpts.IncludePayload = &includePayload + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err != nil { + return nil, err + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + + block := response.Data.GloasContents.Block + generatedRoot, err := block.Body.HashTreeRoot() + require.NoError(t, err) + generatedBodyRoot = generatedRoot + + // Simulate a minimal-preset node: the transport's spec-aware retained + // root deliberately differs from what the generated hasher computes. + retainedBodyRoot = generatedBodyRoot + retainedBodyRoot[0] ^= 0xff + response.Data.BeaconBlockBodyRoot = &retainedBodyRoot + + blockRoot, err := (&phase0.BeaconBlockHeader{ + Slot: block.Slot, + ProposerIndex: block.ProposerIndex, + ParentRoot: block.ParentRoot, + StateRoot: block.StateRoot, + BodyRoot: retainedBodyRoot, + }).HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + + return response, nil + } + + proposalSubmitter := &capturingProposalSubmitter{} + signer := mocksigner.New() + blockSigner := &capturingBeaconBlockSigner{signature: phase0.BLSSignature{0x01}} + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: phase0.BLSSignature{0x03}} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + + require.NoError(t, service.Propose(ctx, duty)) + + require.Equal(t, retainedBodyRoot, blockSigner.bodyRoot) + require.NotEqual(t, generatedBodyRoot, blockSigner.bodyRoot) + + require.NotNil(t, proposalSubmitter.proposal) + require.NotNil(t, proposalSubmitter.proposal.Gloas) + require.NotNil(t, proposalSubmitter.proposal.Gloas.Message) + require.Equal(t, duty.Slot(), proposalSubmitter.proposal.Gloas.Message.Slot) + require.NotNil(t, envelopeSubmitter.opts) + require.NotNil(t, envelopeSubmitter.opts.SignedExecutionPayloadEnvelope) + require.NotNil(t, envelopeSubmitter.opts.SignedExecutionPayloadEnvelope.Gloas) +} + +// TestProposeGloasMissingBodyRootFails proves that a proposal missing its +// retained body root -- as a provider that never set BeaconBlockBodyRoot would +// produce -- fails the duty outright rather than falling back to the generated, +// potentially-wrong Body.HashTreeRoot(). Nothing may be signed or submitted: +// signing over the wrong root would be worse than not proposing at all. +func TestProposeGloasMissingBodyRootFails(t *testing.T) { + ctx := context.Background() + + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + includePayload := true + responseOpts := *opts + responseOpts.IncludePayload = &includePayload + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + if err != nil { + return nil, err + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + // Simulate a provider that never populated the retained root. + response.Data.BeaconBlockBodyRoot = nil + + return response, nil + } + + proposalSubmitter := &capturingProposalSubmitter{} + signer := mocksigner.New() + blockSigner := &capturingBeaconBlockSigner{signature: phase0.BLSSignature{0x01}} + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: phase0.BLSSignature{0x03}} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(nullmetrics.New()), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + + err = service.Propose(ctx, duty) + require.EqualError(t, err, "failed to propose block: failed to calculate hash tree root of ePBS block body: no beacon block body root") + require.Zero(t, blockSigner.calls) + require.Zero(t, envelopeSigner.calls) + require.Nil(t, proposalSubmitter.proposal) + require.Zero(t, proposalSubmitter.calls) + require.Nil(t, envelopeSubmitter.opts) +} + func TestProposeGloasStartsBothSignaturesBeforePublication(t *testing.T) { ctx := context.Background() proposalClient, err := mockconsensusclient.New(ctx) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index daf13978..c5ef5ecb 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -265,14 +265,12 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if err != nil { return errors.Wrap(err, "failed to calculate hash tree root of ePBS block body") } - block := proposal.GloasContents.Block - blockRoot, err := (&phase0.BeaconBlockHeader{ - Slot: block.Slot, - ProposerIndex: block.ProposerIndex, - ParentRoot: block.ParentRoot, - StateRoot: block.StateRoot, - BodyRoot: bodyRoot, - }).HashTreeRoot() + // Use proposal.Root() rather than hashing GloasContents.Block ourselves: the + // generated block hasher inlines mainnet preset sizes, so it is wrong on any + // other preset. Root() derives the block root from the same body root that + // is signed below, and is the same derivation the beacon node client used to + // check the envelope, so this guard and the signature cannot disagree. + blockRoot, err := proposal.Root() if err != nil { return errors.Wrap(err, "failed to calculate hash tree root of ePBS block") } From afa7825a41f2c9920a05ea28b48555b7178f6fb8 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 13:14:04 +0200 Subject: [PATCH 17/30] Handle Gloas cache blocks Recognize Gloas signed blocks without promoting their execution payload bids into execution state. A bid has no execution block number, so the height-indexed gas-limit cache and execution-chain head remain unchanged. Changes: - services/cache/standard/events.go - services/cache/standard/events_internal_test.go Validation: full tests, build, changed-lines custom lint, gopls checks, and diff check passed. The full custom lint reports 50 pre-existing findings outside this change. --- services/cache/standard/events.go | 2 + .../cache/standard/events_internal_test.go | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/services/cache/standard/events.go b/services/cache/standard/events.go index de56767e..216f0588 100644 --- a/services/cache/standard/events.go +++ b/services/cache/standard/events.go @@ -102,6 +102,8 @@ func (s *Service) updateFromBlock(block *spec.VersionedSignedBeaconBlock) { s.log.Trace().Uint64("height", executionPayload.BlockNumber).Stringer("hash", executionPayload.BlockHash).Msg("Updating execution chain head") s.setExecutionChainHead(executionPayload.BlockHash, executionPayload.BlockNumber) } + case spec.DataVersionGloas: + // Gloas blocks contain an execution payload bid, which has no execution block number. default: s.log.Error().Msg("Unhandled block version") } diff --git a/services/cache/standard/events_internal_test.go b/services/cache/standard/events_internal_test.go index facdd479..eea24587 100644 --- a/services/cache/standard/events_internal_test.go +++ b/services/cache/standard/events_internal_test.go @@ -14,11 +14,14 @@ package standard import ( + "bytes" "context" "testing" consensusclient "github.com/attestantio/go-eth2-client" apiv1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" "github.com/rs/zerolog" @@ -68,3 +71,50 @@ func TestHandleHead(t *testing.T) { }) } } + +func TestUpdateFromBlockGloas(t *testing.T) { + ctx := context.Background() + var logs bytes.Buffer + previousRoot := phase0.Hash32{0x01} + previousHeight := uint64(123) + previousGasLimit := uint64(30_000_000) + + s := &Service{ + log: zerolog.New(&logs), + executionChainHeadRoot: previousRoot, + executionChainHeadHeight: previousHeight, + blockGasLimits: map[uint64]uint64{ + previousHeight: previousGasLimit, + }, + } + + s.updateFromBlock(&spec.VersionedSignedBeaconBlock{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedBeaconBlock{ + Message: &gloas.BeaconBlock{ + Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{ + Message: &gloas.ExecutionPayloadBid{ + BlockHash: phase0.Hash32{0x02}, + GasLimit: 31_000_000, + }, + }, + }, + }, + }, + }) + + require.NotContains(t, logs.String(), "Unhandled block version") + + actualRoot, actualHeight := s.ExecutionChainHead(ctx) + require.Equal(t, previousRoot, actualRoot) + require.Equal(t, previousHeight, actualHeight) + + actualGasLimit, exists := s.BlockGasLimit(ctx, previousHeight) + require.True(t, exists) + require.Equal(t, previousGasLimit, actualGasLimit) + + s.blockGasLimitMu.RLock() + defer s.blockGasLimitMu.RUnlock() + require.Equal(t, map[uint64]uint64{previousHeight: previousGasLimit}, s.blockGasLimits) +} From 09d05ed47cc8d87091267b9d830c204e73f0b8ac Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 13:54:53 +0200 Subject: [PATCH 18/30] Decompose the Gloas ePBS proposal paths flagged for complexity Static analysis blocks the branch on nine issues this work introduced: seven functions over the cyclomatic complexity threshold, and two test stubs whose receivers go unused. Split each flagged function along a seam it already had, and drop the two receiver names. - best and first ePBS strategies: lift each strategy's proposal validation out of its response handler, as validateEPBSProposal returning an error for the error channel and acceptableEPBSProposal returning a bool and logging the discard. In best, share the selection logic the soft and hard timeout arms had duplicated; in first, move the per-provider goroutine body into fetchEPBSProposal. The two validators stay separate, mirroring the duplication these two packages already carry rather than inventing a package to hold one shared check. - proposeBlock and proposeEPBSBlock: extract unblindingProviders and epbsProposalEnvelope, the latter carrying over the note on why the proposal's own Root() is used rather than hashing the block. - signer New: fold the five optional domain lookups into optionalDomainType. DOMAIN_BEACON_BUILDER keeps its explicit branch, since it warns that execution payload envelope signing is unavailable. - updateFromBlock: fold Gloas into the existing no-op case list rather than give it an arm of its own, restoring the complexity the function had before this branch. Unifying its six per-fork arms would mean reconciling how Bellatrix through Deneb gate the gas limit and chain head updates against how Electra and Fulu do, which is a behaviour question deserving its own commit. No functional change. The existing ePBS tests are unchanged and are the regression net for every extraction. --- .../beaconblockproposer/standard/propose.go | 98 ++++++++------ services/cache/standard/events.go | 7 +- services/signer/standard/service.go | 39 +++--- .../best/beaconblockproposal.go | 97 ++++++++------ .../best/epbsproposal_test.go | 2 +- .../first/epbsproposal_test.go | 2 +- .../beaconblockproposal/first/service.go | 126 +++++++++++------- 7 files changed, 210 insertions(+), 161 deletions(-) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index c5ef5ecb..ce68f5ef 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -183,27 +183,10 @@ func (s *Service) proposeBlock(ctx context.Context, } if signedProposal.Blinded { - // Select the relays to unblind the proposal. - providers := make([]builderclient.UnblindedProposalProvider, 0, len(auctionResults.AllProviders)) - unblindingCandidates := auctionResults.Providers - if len(unblindingCandidates) == 0 || s.unblindFromAllRelays { - s.log.Trace().Int("providers", len(auctionResults.AllProviders)).Msg("Unblinding from all providers") - unblindingCandidates = auctionResults.AllProviders - } - - for _, provider := range unblindingCandidates { - unblindedProposalProvider, isProvider := provider.(builderclient.UnblindedProposalProvider) - if !isProvider { - s.log.Warn().Str("provider", provider.Name()).Msg("Auctioneer cannot unblind the proposal") - continue - } - providers = append(providers, unblindedProposalProvider) - } - if len(providers) == 0 { - return errors.New("no relays to unblind the block") + providers, err := s.unblindingProviders(auctionResults) + if err != nil { + return err } - - s.log.Trace().Int("providers", len(providers)).Msg("Obtained relays that can unblind the proposal") if err := s.unblindProposal(ctx, signedProposal, providers); err != nil { return errors.Wrap(err, "failed to unblind block") } @@ -216,6 +199,34 @@ func (s *Service) proposeBlock(ctx context.Context, return nil } +// unblindingProviders returns the relays that can unblind a proposal. +func (s *Service) unblindingProviders(auctionResults *blockauctioneer.Results) ([]builderclient.UnblindedProposalProvider, error) { + // Select the relays to unblind the proposal. + providers := make([]builderclient.UnblindedProposalProvider, 0, len(auctionResults.AllProviders)) + unblindingCandidates := auctionResults.Providers + if len(unblindingCandidates) == 0 || s.unblindFromAllRelays { + s.log.Trace().Int("providers", len(auctionResults.AllProviders)).Msg("Unblinding from all providers") + unblindingCandidates = auctionResults.AllProviders + } + + for _, provider := range unblindingCandidates { + unblindedProposalProvider, isProvider := provider.(builderclient.UnblindedProposalProvider) + if !isProvider { + s.log.Warn().Str("provider", provider.Name()).Msg("Auctioneer cannot unblind the proposal") + continue + } + providers = append(providers, unblindedProposalProvider) + } + + if len(providers) == 0 { + return nil, errors.New("no relays to unblind the block") + } + + s.log.Trace().Int("providers", len(providers)).Msg("Obtained relays that can unblind the proposal") + + return providers, nil +} + // proposeEPBSBlock proposes a Gloas block. func (s *Service) proposeEPBSBlock(ctx context.Context, duty *beaconblockproposer.Duty, @@ -257,25 +268,9 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return err } - envelope, err := proposal.ExecutionPayloadEnvelope() - if err != nil { - return errors.Wrap(err, "failed to obtain execution payload envelope") - } - bodyRoot, err := proposal.BodyRoot() - if err != nil { - return errors.Wrap(err, "failed to calculate hash tree root of ePBS block body") - } - // Use proposal.Root() rather than hashing GloasContents.Block ourselves: the - // generated block hasher inlines mainnet preset sizes, so it is wrong on any - // other preset. Root() derives the block root from the same body root that - // is signed below, and is the same derivation the beacon node client used to - // check the envelope, so this guard and the signature cannot disagree. - blockRoot, err := proposal.Root() + envelope, bodyRoot, err := s.epbsProposalEnvelope(proposal) if err != nil { - return errors.Wrap(err, "failed to calculate hash tree root of ePBS block") - } - if blockRoot != envelope.BeaconBlockRoot { - return errors.New("ePBS execution payload envelope is for incorrect block") + return err } var signedProposal *api.VersionedSignedProposal @@ -329,6 +324,33 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return nil } +// epbsProposalEnvelope obtains the execution payload envelope and body root for a proposal, +// confirming that the envelope is for the proposed block. +func (*Service) epbsProposalEnvelope(proposal *api.VersionedEPBSProposal) (*gloas.ExecutionPayloadEnvelope, phase0.Root, error) { + envelope, err := proposal.ExecutionPayloadEnvelope() + if err != nil { + return nil, phase0.Root{}, errors.Wrap(err, "failed to obtain execution payload envelope") + } + bodyRoot, err := proposal.BodyRoot() + if err != nil { + return nil, phase0.Root{}, errors.Wrap(err, "failed to calculate hash tree root of ePBS block body") + } + // Use proposal.Root() rather than hashing GloasContents.Block ourselves: the + // generated block hasher inlines mainnet preset sizes, so it is wrong on any + // other preset. Root() derives the block root from the same body root that + // is signed below, and is the same derivation the beacon node client used to + // check the envelope, so this guard and the signature cannot disagree. + blockRoot, err := proposal.Root() + if err != nil { + return nil, phase0.Root{}, errors.Wrap(err, "failed to calculate hash tree root of ePBS block") + } + if blockRoot != envelope.BeaconBlockRoot { + return nil, phase0.Root{}, errors.New("ePBS execution payload envelope is for incorrect block") + } + + return envelope, bodyRoot, nil +} + func (*Service) confirmProposalData(_ context.Context, proposal *api.VersionedProposal, duty *beaconblockproposer.Duty, diff --git a/services/cache/standard/events.go b/services/cache/standard/events.go index 216f0588..0bf0e97f 100644 --- a/services/cache/standard/events.go +++ b/services/cache/standard/events.go @@ -52,8 +52,9 @@ func (s *Service) handleHead(ctx context.Context, data *apiv1.HeadEvent) { func (s *Service) updateFromBlock(block *spec.VersionedSignedBeaconBlock) { switch block.Version { - case spec.DataVersionPhase0, spec.DataVersionAltair: - // No execution information available, nothing to do. + case spec.DataVersionPhase0, spec.DataVersionAltair, spec.DataVersionGloas: + // Phase 0 and Altair carry no execution payload. + // A Gloas block carries an execution payload bid, which has no execution block number. case spec.DataVersionBellatrix: // Potentially execution information available. if block.Bellatrix != nil && block.Bellatrix.Message != nil && block.Bellatrix.Message.Body != nil { @@ -102,8 +103,6 @@ func (s *Service) updateFromBlock(block *spec.VersionedSignedBeaconBlock) { s.log.Trace().Uint64("height", executionPayload.BlockNumber).Stringer("hash", executionPayload.BlockHash).Msg("Updating execution chain head") s.setExecutionChainHead(executionPayload.BlockHash, executionPayload.BlockNumber) } - case spec.DataVersionGloas: - // Gloas blocks contain an execution payload bid, which has no execution block number. default: s.log.Error().Msg("Unhandled block version") } diff --git a/services/signer/standard/service.go b/services/signer/standard/service.go index b1dae56d..b0385098 100644 --- a/services/signer/standard/service.go +++ b/services/signer/standard/service.go @@ -102,30 +102,11 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { } // The following are optional. - var syncCommitteeDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_SYNC_COMMITTEE"); err == nil { - syncCommitteeDomainType = &tmp - } - - var syncCommitteeSelectionProofDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF"); err == nil { - syncCommitteeSelectionProofDomainType = &tmp - } - - var contributionAndProofDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_CONTRIBUTION_AND_PROOF"); err == nil { - contributionAndProofDomainType = &tmp - } - - var applicationBuilderDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_APPLICATION_BUILDER"); err == nil { - applicationBuilderDomainType = &tmp - } - - var blobSidecarDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_BLOB_SIDECAR"); err == nil { - blobSidecarDomainType = &tmp - } + syncCommitteeDomainType := optionalDomainType(spec, "DOMAIN_SYNC_COMMITTEE") + syncCommitteeSelectionProofDomainType := optionalDomainType(spec, "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF") + contributionAndProofDomainType := optionalDomainType(spec, "DOMAIN_CONTRIBUTION_AND_PROOF") + applicationBuilderDomainType := optionalDomainType(spec, "DOMAIN_APPLICATION_BUILDER") + blobSidecarDomainType := optionalDomainType(spec, "DOMAIN_BLOB_SIDECAR") var beaconBuilderDomainType *phase0.DomainType if tmp, err := domainType(spec, "DOMAIN_BEACON_BUILDER"); err == nil { @@ -166,3 +147,13 @@ func domainType(spec map[string]interface{}, input string) (phase0.DomainType, e } return domainType, nil } + +// optionalDomainType returns the domain type if present in the spec, otherwise nil. +func optionalDomainType(spec map[string]interface{}, input string) *phase0.DomainType { + tmp, err := domainType(spec, input) + if err != nil { + return nil + } + + return &tmp +} diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 307d4d58..ec50e27d 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -87,14 +87,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - if opts.IncludePayload != nil && *opts.IncludePayload && !response.proposal.ExecutionPayloadIncluded { - log.Warn().Str("provider", response.provider).Msg("Discarding ePBS proposal without requested execution payload") - continue - } - if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { - bestProposal = response.proposal - bestProvider = response.provider - } + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -136,14 +129,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - if opts.IncludePayload != nil && *opts.IncludePayload && !response.proposal.ExecutionPayloadIncluded { - log.Warn().Str("provider", response.provider).Msg("Discarding ePBS proposal without requested execution payload") - continue - } - if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { - bestProposal = response.proposal - bestProvider = response.provider - } + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -185,6 +171,27 @@ func (s *Service) EPBSProposal(ctx context.Context, }, nil } +// considerEPBSProposal updates the best proposal seen so far, ignoring proposals that lack a +// requested execution payload. +func considerEPBSProposal(opts *api.EPBSProposalOpts, + response *beaconBlockEPBSResponse, + bestProposal *api.VersionedEPBSProposal, + bestProvider string, + log zerolog.Logger, +) (*api.VersionedEPBSProposal, string) { + if opts.IncludePayload != nil && *opts.IncludePayload && !response.proposal.ExecutionPayloadIncluded { + log.Warn().Str("provider", response.provider).Msg("Discarding ePBS proposal without requested execution payload") + + return bestProposal, bestProvider + } + + if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { + return response.proposal, response.provider + } + + return bestProposal, bestProvider +} + type beaconBlockEPBSResponse struct { provider string proposal *api.VersionedEPBSProposal @@ -237,35 +244,13 @@ func (s *Service) epbsProposal(ctx context.Context, return } - if proposalResponse.Data != nil && proposalResponse.Data.Version == spec.DataVersionGloas { - block := proposalResponse.Data.Gloas - if proposalResponse.Data.ExecutionPayloadIncluded { - if proposalResponse.Data.GloasContents == nil { - errCh <- &beaconBlockError{ - provider: name, - err: errors.New("beacon node returned malformed ePBS proposal"), - } - - return - } - block = proposalResponse.Data.GloasContents.Block - } - if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { - errCh <- &beaconBlockError{ - provider: name, - err: errors.New("beacon node returned malformed ePBS proposal"), - } - - return + if err := validateEPBSProposal(proposalResponse.Data); err != nil { + errCh <- &beaconBlockError{ + provider: name, + err: err, } - if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { - errCh <- &beaconBlockError{ - provider: name, - err: errors.New("beacon block obtained with 0 fee recipient"), - } - return - } + return } respCh <- &beaconBlockEPBSResponse{ @@ -274,6 +259,32 @@ func (s *Service) epbsProposal(ctx context.Context, } } +// validateEPBSProposal confirms that an ePBS proposal is structurally sound and pays a fee recipient. +// The caller must have already excluded a nil proposal. +func validateEPBSProposal(proposal *api.VersionedEPBSProposal) error { + if proposal.Version != spec.DataVersionGloas { + return nil + } + + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded { + if proposal.GloasContents == nil { + return errors.New("beacon node returned malformed ePBS proposal") + } + block = proposal.GloasContents.Block + } + + if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { + return errors.New("beacon node returned malformed ePBS proposal") + } + + if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { + return errors.New("beacon block obtained with 0 fee recipient") + } + + return nil +} + // Proposal provides the best beacon block proposal from a number of beacon nodes. func (s *Service) Proposal(ctx context.Context, opts *api.ProposalOpts, diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index e5b45e6e..46f22ce5 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -642,7 +642,7 @@ func (p *waitingClientGraffitiEPBSProposalProvider) NodeClient( var _ eth2client.NodeClientProvider = (*waitingClientGraffitiEPBSProposalProvider)(nil) -func (p *testEPBSProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { +func (*testEPBSProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { return nil, nil } diff --git a/strategies/beaconblockproposal/first/epbsproposal_test.go b/strategies/beaconblockproposal/first/epbsproposal_test.go index 0e91f24a..2eee0d63 100644 --- a/strategies/beaconblockproposal/first/epbsproposal_test.go +++ b/strategies/beaconblockproposal/first/epbsproposal_test.go @@ -257,7 +257,7 @@ type epbsProposalProvider struct { nilResponse bool } -func (p *epbsProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { +func (*epbsProposalProvider) Proposal(_ context.Context, _ *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { return nil, nil } diff --git a/strategies/beaconblockproposal/first/service.go b/strategies/beaconblockproposal/first/service.go index f085354c..d107ade7 100644 --- a/strategies/beaconblockproposal/first/service.go +++ b/strategies/beaconblockproposal/first/service.go @@ -55,32 +55,7 @@ func (s *Service) EPBSProposal(ctx context.Context, proposalCh := make(chan *api.VersionedEPBSProposal, len(s.proposalProviders)) for name, provider := range s.proposalProviders { - go func(ctx context.Context, name string, provider beaconblockproposer.ProposalDataProvider, ch chan *api.VersionedEPBSProposal) { - log := s.log.With().Str("provider", name).Uint64("slot", uint64(opts.Slot)).Logger() - - started := time.Now() - proposalResponse, err := provider.EPBSProposal(ctx, opts) - s.clientMonitor.ClientOperation(name, "ePBS beacon block proposal", err == nil, time.Since(started)) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Debug().Err(err).Msg("Failed to obtain ePBS beacon block proposal") - } - - return - } - if proposalResponse == nil { - log.Warn().Msg("Discarding empty ePBS proposal response") - - return - } - proposal := proposalResponse.Data - log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained ePBS beacon block proposal") - - select { - case ch <- proposal: - case <-ctx.Done(): - } - }(ctx, name, provider, proposalCh) + go s.fetchEPBSProposal(ctx, name, provider, opts, proposalCh) } for { @@ -90,33 +65,11 @@ func (s *Service) EPBSProposal(ctx context.Context, s.log.Debug().Msg("Failed to obtain ePBS beacon block proposal before timeout") return nil, errors.New("failed to obtain ePBS beacon block proposal before timeout") case proposal := <-proposalCh: - if proposal == nil { - s.log.Warn().Msg("Discarding empty ePBS proposal") - continue - } - if opts.IncludePayload != nil && *opts.IncludePayload && !proposal.ExecutionPayloadIncluded { - s.log.Warn().Msg("Discarding ePBS proposal without requested execution payload") + if !s.acceptableEPBSProposal(proposal, opts.IncludePayload) { continue } - if proposal.Version == spec.DataVersionGloas { - block := proposal.Gloas - if proposal.ExecutionPayloadIncluded { - if proposal.GloasContents == nil { - s.log.Warn().Msg("Discarding malformed ePBS proposal") - continue - } - block = proposal.GloasContents.Block - } - if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { - s.log.Warn().Msg("Discarding malformed ePBS proposal") - continue - } - if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { - s.log.Warn().Msg("Discarding ePBS proposal with 0 fee recipient") - continue - } - } cancel() + return &api.Response[*api.VersionedEPBSProposal]{ Data: proposal, Metadata: make(map[string]any), @@ -125,6 +78,79 @@ func (s *Service) EPBSProposal(ctx context.Context, } } +// fetchEPBSProposal obtains an ePBS beacon block proposal from a single provider, recording the +// operation with the client monitor, and sends the result to ch unless ctx is done first. +func (s *Service) fetchEPBSProposal(ctx context.Context, + name string, + provider beaconblockproposer.ProposalDataProvider, + opts *api.EPBSProposalOpts, + ch chan *api.VersionedEPBSProposal, +) { + log := s.log.With().Str("provider", name).Uint64("slot", uint64(opts.Slot)).Logger() + + started := time.Now() + proposalResponse, err := provider.EPBSProposal(ctx, opts) + s.clientMonitor.ClientOperation(name, "ePBS beacon block proposal", err == nil, time.Since(started)) + if err != nil { + if !errors.Is(err, context.Canceled) { + log.Debug().Err(err).Msg("Failed to obtain ePBS beacon block proposal") + } + + return + } + if proposalResponse == nil { + log.Warn().Msg("Discarding empty ePBS proposal response") + + return + } + proposal := proposalResponse.Data + log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained ePBS beacon block proposal") + + select { + case ch <- proposal: + case <-ctx.Done(): + } +} + +// acceptableEPBSProposal reports whether proposal is usable, discarding and logging it if it is +// nil, lacks a requested execution payload, or (for Gloas) is structurally malformed or pays a +// zero fee recipient. +func (s *Service) acceptableEPBSProposal(proposal *api.VersionedEPBSProposal, includePayload *bool) bool { + if proposal == nil { + s.log.Warn().Msg("Discarding empty ePBS proposal") + + return false + } + if includePayload != nil && *includePayload && !proposal.ExecutionPayloadIncluded { + s.log.Warn().Msg("Discarding ePBS proposal without requested execution payload") + + return false + } + if proposal.Version == spec.DataVersionGloas { + block := proposal.Gloas + if proposal.ExecutionPayloadIncluded { + if proposal.GloasContents == nil { + s.log.Warn().Msg("Discarding malformed ePBS proposal") + + return false + } + block = proposal.GloasContents.Block + } + if block == nil || block.Body == nil || block.Body.SignedExecutionPayloadBid == nil || block.Body.SignedExecutionPayloadBid.Message == nil { + s.log.Warn().Msg("Discarding malformed ePBS proposal") + + return false + } + if block.Body.SignedExecutionPayloadBid.Message.FeeRecipient.IsZero() { + s.log.Warn().Msg("Discarding ePBS proposal with 0 fee recipient") + + return false + } + } + + return true +} + // New creates a new beacon block proposal strategy. func New(_ context.Context, params ...Parameter) (*Service, error) { parameters, err := parseAndCheckParameters(params...) From 13c9595e52e71fc3748ccc5e8ac5a6d0e7dddac2 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 17:37:44 +0200 Subject: [PATCH 19/30] improve Gloas proposal telemetry Add missing immediate envelope submission and per-provider ePBS spans. Classify payload-included self-builds as local and payload-excluded builder responses as builder while retaining the self-build rejection. --- .../standard/gloas_test.go | 44 ++++++++++++++++++- .../beaconblockproposer/standard/metrics.go | 2 +- .../beaconblockproposer/standard/propose.go | 6 ++- .../submitexecutionpayloadenvelope.go | 4 ++ .../submitexecutionpayloadenvelope_test.go | 27 +++++++++++- .../best/beaconblockproposal.go | 5 +++ .../best/epbsproposal_test.go | 33 ++++++++++++++ 7 files changed, 117 insertions(+), 4 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 2270ab4a..cf9792aa 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -31,12 +31,15 @@ import ( "github.com/attestantio/vouch/services/cache" mockcache "github.com/attestantio/vouch/services/cache/mock" "github.com/attestantio/vouch/services/chaintime" + "github.com/attestantio/vouch/services/metrics" nullmetrics "github.com/attestantio/vouch/services/metrics/null" + prometheusmetrics "github.com/attestantio/vouch/services/metrics/prometheus" "github.com/attestantio/vouch/services/signer" mocksigner "github.com/attestantio/vouch/services/signer/mock" "github.com/attestantio/vouch/services/submitter" "github.com/attestantio/vouch/testing/logger" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/rs/zerolog" "github.com/stretchr/testify/require" e2types "github.com/wealdtech/go-eth2-types/v2" @@ -57,12 +60,14 @@ func TestProposeGloas(t *testing.T) { forkEpochAtConstruction phase0.Epoch forkEpochAtUse phase0.Epoch updateForkEpochAtUse bool + proposalSource string err string }{ { name: "PayloadIncluded", executionPayloadIncluded: true, builderBoostFactor: 100, + proposalSource: "local", }, { name: "ForkEpochAvailableAfterConstruction", @@ -85,6 +90,7 @@ func TestProposeGloas(t *testing.T) { { name: "PayloadExcluded", executionPayloadIncluded: false, + proposalSource: "builder", err: "failed to propose block: ePBS proposal excludes requested execution payload", }, { @@ -147,10 +153,18 @@ func TestProposeGloas(t *testing.T) { envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} chainTime := &forkChainTime{gloasForkEpoch: test.forkEpochAtConstruction} + var monitor metrics.Service = nullmetrics.New() + if test.proposalSource != "" { + monitor, err = prometheusmetrics.New(ctx, + prometheusmetrics.WithLogLevel(zerolog.Disabled), + prometheusmetrics.WithAddress("localhost:0"), + ) + require.NoError(t, err) + } params := []standard.Parameter{ standard.WithLogLevel(zerolog.TraceLevel), - standard.WithMonitor(nullmetrics.New()), + standard.WithMonitor(monitor), standard.WithProposalDataProvider(proposalClient), standard.WithChainTime(chainTime), standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), @@ -171,6 +185,10 @@ func TestProposeGloas(t *testing.T) { } service, err := standard.New(ctx, params...) require.NoError(t, err) + var proposalSourceCountBefore float64 + if test.proposalSource != "" { + proposalSourceCountBefore = beaconBlockProposalSourceCount(t, test.proposalSource) + } if test.updateForkEpochAtUse { chainTime.gloasForkEpoch = test.forkEpochAtUse } @@ -180,6 +198,9 @@ func TestProposeGloas(t *testing.T) { duty.SetRandaoReveal(phase0.BLSSignature{0x02}) err = service.Propose(ctx, duty) + if test.proposalSource != "" { + require.Equal(t, proposalSourceCountBefore+1, beaconBlockProposalSourceCount(t, test.proposalSource)) + } if test.builderBoostFactor != 0 { capture.AssertHasEntry(t, "Ignoring non-default builder boost factor on Gloas proposal path") } @@ -244,6 +265,27 @@ func TestProposeGloas(t *testing.T) { } } +func beaconBlockProposalSourceCount(t *testing.T, source string) float64 { + t.Helper() + + metricFamilies, err := prometheus.DefaultGatherer.Gather() + require.NoError(t, err) + for _, metricFamily := range metricFamilies { + if metricFamily.GetName() != "vouch_beaconblockproposal_process_blocks_total" { + continue + } + for _, metric := range metricFamily.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "method" && label.GetValue() == source { + return metric.GetCounter().GetValue() + } + } + } + } + + return 0 +} + // TestProposeGloasSignsRetainedBodyRoot proves that Propose signs and submits the // body root retained on the proposal (BeaconBlockBodyRoot), not the body root the // generated Body.HashTreeRoot() would compute. On a custom preset those two roots diff --git a/services/beaconblockproposer/standard/metrics.go b/services/beaconblockproposer/standard/metrics.go index dbaca075..e6a012a2 100644 --- a/services/beaconblockproposer/standard/metrics.go +++ b/services/beaconblockproposer/standard/metrics.go @@ -109,7 +109,7 @@ func registerPrometheusMetrics(_ context.Context) error { Namespace: "vouch", Subsystem: "beaconblockproposal_process", Name: "blocks_total", - Help: "The number of beacon block proposals. method can be either local or relay", + Help: "The number of beacon block proposals. method can be local, relay, or builder", }, []string{"method"}) if err := prometheus.Register(beaconBlockProposalSource); err != nil { return err diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index ce68f5ef..94a91fdf 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -259,7 +259,11 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return errors.Wrap(err, "failed to obtain ePBS proposal") } proposal := proposalResponse.Data - monitorBeaconBlockProposalSource("local") + if proposal.ExecutionPayloadIncluded { + monitorBeaconBlockProposalSource("local") + } else { + monitorBeaconBlockProposalSource("builder") + } if !proposal.ExecutionPayloadIncluded { return errors.New("ePBS proposal excludes requested execution payload") } diff --git a/services/submitter/immediate/submitexecutionpayloadenvelope.go b/services/submitter/immediate/submitexecutionpayloadenvelope.go index cce7080e..950924b6 100644 --- a/services/submitter/immediate/submitexecutionpayloadenvelope.go +++ b/services/submitter/immediate/submitexecutionpayloadenvelope.go @@ -20,10 +20,14 @@ import ( eth2client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" "github.com/pkg/errors" + "go.opentelemetry.io/otel" ) // SubmitExecutionPayloadEnvelope submits a signed execution payload envelope. func (s *Service) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + ctx, span := otel.Tracer("attestantio.vouch.services.submitter.immediate").Start(ctx, "SubmitExecutionPayloadEnvelope") + defer span.End() + if opts == nil { return errors.New("no execution payload envelope supplied") } diff --git a/services/submitter/immediate/submitexecutionpayloadenvelope_test.go b/services/submitter/immediate/submitexecutionpayloadenvelope_test.go index 09f4e9b7..09644dea 100644 --- a/services/submitter/immediate/submitexecutionpayloadenvelope_test.go +++ b/services/submitter/immediate/submitexecutionpayloadenvelope_test.go @@ -23,10 +23,22 @@ import ( "github.com/attestantio/vouch/services/submitter/immediate" "github.com/rs/zerolog" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" ) func TestSubmitExecutionPayloadEnvelope(t *testing.T) { ctx := context.Background() + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + previousTracerProvider := otel.GetTracerProvider() + otel.SetTracerProvider(tracerProvider) + t.Cleanup(func() { + otel.SetTracerProvider(previousTracerProvider) + require.NoError(t, tracerProvider.Shutdown(ctx)) + }) capture := &capturingExecutionPayloadEnvelopeSubmitter{} service, err := immediate.New(ctx, immediate.WithLogLevel(zerolog.Disabled), @@ -45,13 +57,26 @@ func TestSubmitExecutionPayloadEnvelope(t *testing.T) { opts := &api.SubmitExecutionPayloadEnvelopeOpts{} require.NoError(t, service.SubmitExecutionPayloadEnvelope(ctx, opts)) require.Same(t, opts, capture.opts) + + var envelopeSubmissionSpan sdktrace.ReadOnlySpan + for _, span := range spanRecorder.Ended() { + if span.Name() == "SubmitExecutionPayloadEnvelope" { + envelopeSubmissionSpan = span + break + } + } + require.NotNil(t, envelopeSubmissionSpan, "submission should create an execution payload envelope span") + require.Equal(t, "attestantio.vouch.services.submitter.immediate", envelopeSubmissionSpan.InstrumentationScope().Name) + require.Equal(t, envelopeSubmissionSpan.SpanContext(), trace.SpanFromContext(capture.ctx).SpanContext()) } type capturingExecutionPayloadEnvelopeSubmitter struct { + ctx context.Context opts *api.SubmitExecutionPayloadEnvelopeOpts } -func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(_ context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { +func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + s.ctx = ctx s.opts = opts return nil } diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index ec50e27d..9951b89a 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -206,6 +206,11 @@ func (s *Service) epbsProposal(ctx context.Context, opts *api.EPBSProposalOpts, log zerolog.Logger, ) { + ctx, span := otel.Tracer("attestantio.vouch.strategies.beaconblockproposal.best").Start(ctx, "ePBSBeaconBlockProposal", trace.WithAttributes( + attribute.String("provider", name), + )) + defer span.End() + providerGraffiti := opts.Graffiti[:] if bytes.Contains(providerGraffiti, []byte("{{CLIENT}}")) { if nodeClientProvider, isProvider := provider.(eth2client.NodeClientProvider); isProvider { diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index 46f22ce5..db8d8793 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -37,10 +37,21 @@ import ( "github.com/attestantio/vouch/strategies/beaconblockproposal/best" "github.com/rs/zerolog" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" ) func TestEPBSProposal(t *testing.T) { ctx := context.Background() + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + previousTracerProvider := otel.GetTracerProvider() + otel.SetTracerProvider(tracerProvider) + t.Cleanup(func() { + otel.SetTracerProvider(previousTracerProvider) + require.NoError(t, tracerProvider.Shutdown(ctx)) + }) specProvider := mock.NewSpecProvider() chainTime, err := standardchaintime.New(ctx, standardchaintime.WithLogLevel(zerolog.Disabled), @@ -58,6 +69,7 @@ func TestEPBSProposal(t *testing.T) { best.WithSpecProvider(specProvider), best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ "one": &testEPBSProposalProvider{proposal: testGloasProposal(1, bellatrix.ExecutionAddress{0x01})}, + "two": &testEPBSProposalProvider{proposal: testGloasProposal(1, bellatrix.ExecutionAddress{0x02})}, }), best.WithTimeout(time.Second), best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), @@ -70,6 +82,27 @@ func TestEPBSProposal(t *testing.T) { require.NoError(t, err) require.NotNil(t, response) require.NotNil(t, response.Data) + + var epbsProposalSpan sdktrace.ReadOnlySpan + providerSpans := make(map[string]sdktrace.ReadOnlySpan) + for _, span := range spanRecorder.Ended() { + switch span.Name() { + case "EPBSProposal": + epbsProposalSpan = span + case "ePBSBeaconBlockProposal": + for _, attribute := range span.Attributes() { + if string(attribute.Key) == "provider" { + providerSpans[attribute.Value.AsString()] = span + } + } + } + } + require.NotNil(t, epbsProposalSpan) + for _, provider := range []string{"one", "two"} { + span, exists := providerSpans[provider] + require.True(t, exists, "provider %q should create a span", provider) + require.Equal(t, epbsProposalSpan.SpanContext(), span.Parent()) + } } func TestEPBSProposalReturnsIncludedCandidateAtSoftTimeout(t *testing.T) { From e41404ae3601a72713c0eb4cb9dcf4ef30d3226f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Thu, 13 Aug 2026 18:25:28 +0200 Subject: [PATCH 20/30] fix Gloas telemetry complexity Keep response-derived source telemetry and safety coverage while splitting metric assertions from the general proposer test to satisfy the DeepSource complexity threshold. --- .../standard/gloas_test.go | 124 +++++++++++++++--- .../beaconblockproposer/standard/propose.go | 7 +- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index cf9792aa..31c5cdb8 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -60,14 +60,12 @@ func TestProposeGloas(t *testing.T) { forkEpochAtConstruction phase0.Epoch forkEpochAtUse phase0.Epoch updateForkEpochAtUse bool - proposalSource string err string }{ { name: "PayloadIncluded", executionPayloadIncluded: true, builderBoostFactor: 100, - proposalSource: "local", }, { name: "ForkEpochAvailableAfterConstruction", @@ -90,7 +88,6 @@ func TestProposeGloas(t *testing.T) { { name: "PayloadExcluded", executionPayloadIncluded: false, - proposalSource: "builder", err: "failed to propose block: ePBS proposal excludes requested execution payload", }, { @@ -154,13 +151,6 @@ func TestProposeGloas(t *testing.T) { chainTime := &forkChainTime{gloasForkEpoch: test.forkEpochAtConstruction} var monitor metrics.Service = nullmetrics.New() - if test.proposalSource != "" { - monitor, err = prometheusmetrics.New(ctx, - prometheusmetrics.WithLogLevel(zerolog.Disabled), - prometheusmetrics.WithAddress("localhost:0"), - ) - require.NoError(t, err) - } params := []standard.Parameter{ standard.WithLogLevel(zerolog.TraceLevel), @@ -185,10 +175,6 @@ func TestProposeGloas(t *testing.T) { } service, err := standard.New(ctx, params...) require.NoError(t, err) - var proposalSourceCountBefore float64 - if test.proposalSource != "" { - proposalSourceCountBefore = beaconBlockProposalSourceCount(t, test.proposalSource) - } if test.updateForkEpochAtUse { chainTime.gloasForkEpoch = test.forkEpochAtUse } @@ -198,9 +184,6 @@ func TestProposeGloas(t *testing.T) { duty.SetRandaoReveal(phase0.BLSSignature{0x02}) err = service.Propose(ctx, duty) - if test.proposalSource != "" { - require.Equal(t, proposalSourceCountBefore+1, beaconBlockProposalSourceCount(t, test.proposalSource)) - } if test.builderBoostFactor != 0 { capture.AssertHasEntry(t, "Ignoring non-default builder boost factor on Gloas proposal path") } @@ -265,6 +248,113 @@ func TestProposeGloas(t *testing.T) { } } +func TestProposeGloasProposalSource(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + executionPayloadIncluded bool + source string + err string + }{ + { + name: "SelfBuiltPayload", + executionPayloadIncluded: true, + source: "local", + }, + { + name: "ProtocolBuilderPayload", + executionPayloadIncluded: false, + source: "builder", + err: "failed to propose block: ePBS proposal excludes requested execution payload", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + monitor, err := prometheusmetrics.New(ctx, + prometheusmetrics.WithLogLevel(zerolog.Disabled), + prometheusmetrics.WithAddress("localhost:0"), + ) + require.NoError(t, err) + proposalSourceCountBefore := beaconBlockProposalSourceCount(t, test.source) + service, duty, blockSigner, envelopeSigner, envelopeSubmitter := newGloasProposerForProposalSource(t, ctx, test.executionPayloadIncluded, monitor) + + err = service.Propose(ctx, duty) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + require.Equal(t, proposalSourceCountBefore+1, beaconBlockProposalSourceCount(t, test.source)) + if !test.executionPayloadIncluded { + require.Zero(t, blockSigner.calls) + require.Zero(t, envelopeSigner.calls) + require.Nil(t, envelopeSubmitter.opts) + } + }) + } +} + +func newGloasProposerForProposalSource(t *testing.T, + ctx context.Context, + executionPayloadIncluded bool, + monitor metrics.Service, +) (*standard.Service, + *beaconblockproposer.Duty, + *capturingBeaconBlockSigner, + *capturingExecutionPayloadEnvelopeSigner, + *capturingExecutionPayloadEnvelopeSubmitter, +) { + t.Helper() + + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + responseClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(ctx context.Context, opts *consensusapi.EPBSProposalOpts) (*consensusapi.Response[*consensusapi.VersionedEPBSProposal], error) { + responseOpts := *opts + responseOpts.IncludePayload = &executionPayloadIncluded + response, err := responseClient.EPBSProposal(ctx, &responseOpts) + require.NoError(t, err) + if response.Data.ExecutionPayloadIncluded { + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() + require.NoError(t, err) + response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot + } + + return response, nil + } + + proposalSubmitter := &capturingProposalSubmitter{} + signer := mocksigner.New() + blockSigner := &capturingBeaconBlockSigner{signature: phase0.BLSSignature{0x01}} + envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: phase0.BLSSignature{0x03}} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.Disabled), + standard.WithMonitor(monitor), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(&forkChainTime{gloasForkEpoch: 0}), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + ) + require.NoError(t, err) + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + duty.SetRandaoReveal(phase0.BLSSignature{0x02}) + + return service, duty, blockSigner, envelopeSigner, envelopeSubmitter +} + func beaconBlockProposalSourceCount(t *testing.T, source string) float64 { t.Helper() diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 94a91fdf..be69fb98 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -259,14 +259,11 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return errors.Wrap(err, "failed to obtain ePBS proposal") } proposal := proposalResponse.Data - if proposal.ExecutionPayloadIncluded { - monitorBeaconBlockProposalSource("local") - } else { - monitorBeaconBlockProposalSource("builder") - } if !proposal.ExecutionPayloadIncluded { + monitorBeaconBlockProposalSource("builder") return errors.New("ePBS proposal excludes requested execution payload") } + monitorBeaconBlockProposalSource("local") if err := s.confirmEPBSProposalData(ctx, proposal, duty); err != nil { return err From bb5cb2a4fbf90f58ad887c3b592bad48655b476a Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 09:48:09 +0200 Subject: [PATCH 21/30] Fix ctx should be the first parameter of a function --- services/beaconblockproposer/standard/gloas_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 31c5cdb8..d86fcd17 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -278,7 +278,7 @@ func TestProposeGloasProposalSource(t *testing.T) { ) require.NoError(t, err) proposalSourceCountBefore := beaconBlockProposalSourceCount(t, test.source) - service, duty, blockSigner, envelopeSigner, envelopeSubmitter := newGloasProposerForProposalSource(t, ctx, test.executionPayloadIncluded, monitor) + service, duty, blockSigner, envelopeSigner, envelopeSubmitter := newGloasProposerForProposalSource(ctx, t, test.executionPayloadIncluded, monitor) err = service.Propose(ctx, duty) if test.err != "" { @@ -296,8 +296,9 @@ func TestProposeGloasProposalSource(t *testing.T) { } } -func newGloasProposerForProposalSource(t *testing.T, +func newGloasProposerForProposalSource( ctx context.Context, + t *testing.T, executionPayloadIncluded bool, monitor metrics.Service, ) (*standard.Service, From ff5c0fd72f38ba329510c9811894934c5a7973b8 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 12:01:03 +0200 Subject: [PATCH 22/30] fix vulnerable Go Docker builder Build the runtime binary with the fixed Go 1.26.6 Bookworm image to resolve the Trivy-reported standard library vulnerability. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 39993265..f615c201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.6-bookworm AS builder WORKDIR /app From 8d73d72bd4753e86a5c6eee9e2e4ab1066e3aead Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 12:22:46 +0200 Subject: [PATCH 23/30] fix: harden Gloas ePBS proposal handling --- clients_test.go | 48 ++++++++++ main.go | 14 ++- .../standard/gloas_test.go | 81 +++++++++++++---- .../beaconblockproposer/standard/propose.go | 31 ++++++- .../best/beaconblockproposal.go | 40 ++++++--- .../best/beaconblockproposal_test.go | 46 ++++++++++ .../best/epbsproposal_test.go | 88 ++++++++++++++++++- 7 files changed, 309 insertions(+), 39 deletions(-) diff --git a/clients_test.go b/clients_test.go index 538550bc..6db48091 100644 --- a/clients_test.go +++ b/clients_test.go @@ -19,13 +19,18 @@ import ( 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" @@ -95,3 +100,46 @@ func TestFetchClientCustomSpecSupport(t *testing.T) { 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") +} diff --git a/main.go b/main.go index c9861f7a..161a7728 100644 --- a/main.go +++ b/main.go @@ -1462,11 +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") } - var isProvider bool - proposalProvider, isProvider = beaconBlockProposalClient.(beaconblockproposer.ProposalDataProvider) + 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 diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index d86fcd17..f0f7236e 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -50,17 +50,21 @@ func TestProposeGloas(t *testing.T) { ctx := context.Background() tests := []struct { - name string - executionPayloadIncluded bool - blockAuctioneer bool - builderBoostFactor uint64 - proposerIndexMismatch bool - envelopeRootMismatch bool - envelopeSignerErr error - forkEpochAtConstruction phase0.Epoch - forkEpochAtUse phase0.Epoch - updateForkEpochAtUse bool - err string + name string + executionPayloadIncluded bool + blockAuctioneer bool + builderBoostFactor uint64 + proposerIndexMismatch bool + builderIndexMismatch bool + envelopeRootMismatch bool + envelopePayloadMissing bool + executionPayloadBidMissing bool + envelopeSignerErr error + envelopeSubmitterErr error + forkEpochAtConstruction phase0.Epoch + forkEpochAtUse phase0.Epoch + updateForkEpochAtUse bool + err string }{ { name: "PayloadIncluded", @@ -85,6 +89,24 @@ func TestProposeGloas(t *testing.T) { proposerIndexMismatch: true, err: "failed to propose block: ePBS proposal data for incorrect proposer index", }, + { + name: "MismatchedEnvelopeBuilderIndex", + executionPayloadIncluded: true, + builderIndexMismatch: true, + err: "failed to propose block: ePBS execution payload envelope is for incorrect builder index", + }, + { + name: "MissingEnvelopePayload", + executionPayloadIncluded: true, + envelopePayloadMissing: true, + err: "failed to propose block: ePBS execution payload envelope has no payload", + }, + { + name: "MissingExecutionPayloadBid", + executionPayloadIncluded: true, + executionPayloadBidMissing: true, + err: "failed to propose block: ePBS proposal has no execution payload bid", + }, { name: "PayloadExcluded", executionPayloadIncluded: false, @@ -102,6 +124,12 @@ func TestProposeGloas(t *testing.T) { envelopeSignerErr: errors.New("envelope signing failed"), err: "failed to propose block: failed to sign execution payload envelope: envelope signing failed", }, + { + name: "EnvelopeSubmissionFailure", + executionPayloadIncluded: true, + envelopeSubmitterErr: errors.New("envelope submission failed"), + err: "failed to propose block: failed to submit execution payload envelope after block publication: envelope submission failed", + }, } for _, test := range tests { @@ -134,6 +162,15 @@ func TestProposeGloas(t *testing.T) { if test.envelopeRootMismatch { response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot[0] ^= 0xff } + if test.builderIndexMismatch { + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex++ + } + if test.envelopePayloadMissing { + response.Data.GloasContents.ExecutionPayloadEnvelope.Payload = nil + } + if test.executionPayloadBidMissing { + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid = nil + } } if err == nil { responseProposal = response.Data @@ -147,7 +184,7 @@ func TestProposeGloas(t *testing.T) { blockSigner := &capturingBeaconBlockSigner{signature: signature} envelopeSignature := phase0.BLSSignature{0x03} envelopeSigner := &capturingExecutionPayloadEnvelopeSigner{signature: envelopeSignature, err: test.envelopeSignerErr} - envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{err: test.envelopeSubmitterErr} chainTime := &forkChainTime{gloasForkEpoch: test.forkEpochAtConstruction} var monitor metrics.Service = nullmetrics.New() @@ -197,9 +234,15 @@ func TestProposeGloas(t *testing.T) { require.Equal(t, uint64(0), *epbsOpts.BuilderBoostFactor) if test.err != "" { require.EqualError(t, err, test.err) - require.Nil(t, proposalSubmitter.proposal) - require.Zero(t, proposalSubmitter.calls) - if test.envelopeRootMismatch { + if test.envelopeSubmitterErr == nil { + require.Nil(t, proposalSubmitter.proposal) + require.Zero(t, proposalSubmitter.calls) + } else { + require.NotNil(t, proposalSubmitter.proposal) + require.Equal(t, 1, proposalSubmitter.calls) + require.Equal(t, 3, envelopeSubmitter.calls) + } + if test.envelopeRootMismatch || test.builderIndexMismatch || test.envelopePayloadMissing || test.executionPayloadBidMissing { require.Zero(t, blockSigner.calls) require.Zero(t, envelopeSigner.calls) require.Nil(t, envelopeSubmitter.opts) @@ -938,15 +981,19 @@ func (s *contextBlockingBeaconBlockSigner) SignBeaconBlockProposal( var _ signer.BeaconBlockSigner = (*contextBlockingBeaconBlockSigner)(nil) type capturingExecutionPayloadEnvelopeSubmitter struct { - opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts + opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts + calls int + err error } func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope( _ context.Context, opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts, ) error { + s.calls++ s.opts = opts - return nil + + return s.err } var _ submitter.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index be69fb98..d750127c 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -308,7 +308,7 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, return errors.Wrap(err, "failed to submit proposal") } - if err := s.executionPayloadEnvelopeSubmitter.SubmitExecutionPayloadEnvelope(ctx, &api.SubmitExecutionPayloadEnvelopeOpts{ + envelopeSubmissionOpts := &api.SubmitExecutionPayloadEnvelopeOpts{ SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ Version: spec.DataVersionGloas, Gloas: &gloas.SignedExecutionPayloadEnvelope{ @@ -318,8 +318,24 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, }, KZGProofs: kzgProofs, Blobs: blobs, - }); err != nil { - return errors.Wrap(err, "failed to submit execution payload envelope") + } + var envelopeSubmissionErr error + for attempts := 3; attempts > 0; attempts-- { + envelopeSubmissionErr = s.executionPayloadEnvelopeSubmitter.SubmitExecutionPayloadEnvelope(ctx, envelopeSubmissionOpts) + if envelopeSubmissionErr == nil { + break + } + s.log.Warn().Err(envelopeSubmissionErr).Int("attempts_remaining", attempts-1).Msg("Failed to submit execution payload envelope after block publication") + if attempts > 1 { + select { + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "failed to submit execution payload envelope after block publication") + case <-time.After(250 * time.Millisecond): + } + } + } + if envelopeSubmissionErr != nil { + return errors.Wrap(envelopeSubmissionErr, "failed to submit execution payload envelope after block publication") } return nil @@ -332,6 +348,15 @@ func (*Service) epbsProposalEnvelope(proposal *api.VersionedEPBSProposal) (*gloa if err != nil { return nil, phase0.Root{}, errors.Wrap(err, "failed to obtain execution payload envelope") } + if envelope.Payload == nil { + return nil, phase0.Root{}, errors.New("ePBS execution payload envelope has no payload") + } + if proposal.GloasContents == nil || proposal.GloasContents.Block == nil || proposal.GloasContents.Block.Body == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message == nil { + return nil, phase0.Root{}, errors.New("ePBS proposal has no execution payload bid") + } + if envelope.BuilderIndex != proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex { + return nil, phase0.Root{}, errors.New("ePBS execution payload envelope is for incorrect builder index") + } bodyRoot, err := proposal.BodyRoot() if err != nil { return nil, phase0.Root{}, errors.Wrap(err, "failed to calculate hash tree root of ePBS block body") diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 9951b89a..24137f0f 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -87,7 +87,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + bestProposal, bestProvider = s.considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -129,7 +129,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + bestProposal, bestProvider = s.considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -173,7 +173,7 @@ func (s *Service) EPBSProposal(ctx context.Context, // considerEPBSProposal updates the best proposal seen so far, ignoring proposals that lack a // requested execution payload. -func considerEPBSProposal(opts *api.EPBSProposalOpts, +func (s *Service) considerEPBSProposal(opts *api.EPBSProposalOpts, response *beaconBlockEPBSResponse, bestProposal *api.VersionedEPBSProposal, bestProvider string, @@ -185,13 +185,30 @@ func considerEPBSProposal(opts *api.EPBSProposalOpts, return bestProposal, bestProvider } - if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { + if bestProposal == nil || s.scoreEPBSProposal(response.proposal).Cmp(s.scoreEPBSProposal(bestProposal)) > 0 { return response.proposal, response.provider } return bestProposal, bestProvider } +func (s *Service) scoreEPBSProposal(proposal *api.VersionedEPBSProposal) *big.Rat { + score := new(big.Rat).SetInt(proposal.Value()) + if proposal.ConsensusValue == nil && proposal.ExecutionValue == nil { + s.log.Warn().Msg("ePBS proposal has no value headers; scoring execution payload gas only") + } + if proposal.ExecutionPayloadIncluded && proposal.GloasContents != nil && proposal.GloasContents.ExecutionPayloadEnvelope != nil && proposal.GloasContents.ExecutionPayloadEnvelope.Payload != nil { + payload := proposal.GloasContents.ExecutionPayloadEnvelope.Payload + if factor := new(big.Rat).SetFloat64(s.executionPayloadFactor); factor != nil { + gasUsed := new(big.Int).SetUint64(payload.GasUsed) + gasUsed.Add(gasUsed, new(big.Int).SetUint64(payload.BlobGasUsed)) + score.Add(score, new(big.Rat).Mul(new(big.Rat).SetInt(gasUsed), factor)) + } + } + + return score +} + type beaconBlockEPBSResponse struct { provider string proposal *api.VersionedEPBSProposal @@ -319,7 +336,8 @@ func (s *Service) Proposal(ctx context.Context, errCh := make(chan *beaconBlockError, requests) // Kick off the requests. for name, provider := range s.proposalProviders { - providerGraffiti := opts.Graffiti[:] + providerOpts := *opts + providerGraffiti := providerOpts.Graffiti[:] if bytes.Contains(providerGraffiti, []byte("{{CLIENT}}")) { if nodeClientProvider, isProvider := provider.(eth2client.NodeClientProvider); isProvider { nodeClientResponse, err := nodeClientProvider.NodeClient(ctx) @@ -331,16 +349,12 @@ func (s *Service) Proposal(ctx context.Context, if len(providerGraffiti) > 32 { providerGraffiti = providerGraffiti[0:32] } - // Replace entire opts structure so the mutated graffiti does not leak to other providers. - opts = &api.ProposalOpts{ - Slot: opts.Slot, - RandaoReveal: opts.RandaoReveal, - Graffiti: [32]byte(providerGraffiti), - SkipRandaoVerification: opts.SkipRandaoVerification, - } + var graffiti [32]byte + copy(graffiti[:], providerGraffiti) + providerOpts.Graffiti = graffiti } } - go s.beaconBlockProposal(ctx, started, name, provider, respCh, errCh, opts) + go s.beaconBlockProposal(ctx, started, name, provider, respCh, errCh, &providerOpts) } // Wait for all responses (or context done). diff --git a/strategies/beaconblockproposal/best/beaconblockproposal_test.go b/strategies/beaconblockproposal/best/beaconblockproposal_test.go index 53d3eb40..f51a7f11 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal_test.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal_test.go @@ -25,6 +25,7 @@ import ( "github.com/attestantio/vouch/services/cache" mockcache "github.com/attestantio/vouch/services/cache/mock" standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" + nullmetrics "github.com/attestantio/vouch/services/metrics/null" "github.com/attestantio/vouch/strategies/beaconblockproposal/best" "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" @@ -188,3 +189,48 @@ func TestProposal(t *testing.T) { }) } } + +func TestProposalExpandsShortClientGraffiti(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + provider := &clientGraffitiEPBSProposalProvider{ + client: "prysm", + graffiti: make(chan [32]byte, 1), + } + secondProvider := &clientGraffitiEPBSProposalProvider{ + client: "nimbus", + graffiti: make(chan [32]byte, 1), + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "prysm": provider, + "nimbus": secondProvider, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + var graffiti [32]byte + copy(graffiti[:], "configured {{CLIENT}}") + + _, err = service.Proposal(ctx, &api.ProposalOpts{Slot: 1, Graffiti: graffiti}) + require.NoError(t, err) + var expected [32]byte + copy(expected[:], "configured prysm") + require.Equal(t, expected, <-provider.graffiti) + var secondExpected [32]byte + copy(secondExpected[:], "configured nimbus") + require.Equal(t, secondExpected, <-secondProvider.graffiti) +} diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index db8d8793..95f2b451 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -35,6 +35,7 @@ import ( standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" nullmetrics "github.com/attestantio/vouch/services/metrics/null" "github.com/attestantio/vouch/strategies/beaconblockproposal/best" + "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -243,6 +244,80 @@ func TestEPBSProposalRejectsZeroFeeRecipientWithoutPayload(t *testing.T) { require.Same(t, validCandidate, response.Data) } +func TestEPBSProposalWeightsExecutionPayloadGas(t *testing.T) { + ctx := context.Background() + capture := logger.NewLogCapture() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + consensusCandidate := testGloasProposal(2, bellatrix.ExecutionAddress{0x01}) + executionCandidate := testGloasProposal(0, bellatrix.ExecutionAddress{0x02}) + executionCandidate.ConsensusValue = nil + executionCandidate.GloasContents.ExecutionPayloadEnvelope = &gloas.ExecutionPayloadEnvelope{ + Payload: &gloas.ExecutionPayload{GasUsed: 3}, + } + service, err := best.New(ctx, + best.WithLogLevel(zerolog.WarnLevel), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "consensus": &testEPBSProposalProvider{proposal: consensusCandidate}, + "execution": &testEPBSProposalProvider{proposal: executionCandidate}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + best.WithExecutionPayloadFactor(1), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.Same(t, executionCandidate, response.Data) + capture.AssertHasEntry(t, "ePBS proposal has no value headers; scoring execution payload gas only") +} + +func TestEPBSProposalComparesLargeValuesExactly(t *testing.T) { + ctx := context.Background() + specProvider := mock.NewSpecProvider() + chainTime, err := standardchaintime.New(ctx, + standardchaintime.WithLogLevel(zerolog.Disabled), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + cacheSvc := mockcache.New(map[phase0.Root]phase0.Slot{}) + base := new(big.Int).Lsh(big.NewInt(1), 54) + lowerValueCandidate := testGloasProposal(0, bellatrix.ExecutionAddress{0x01}) + lowerValueCandidate.ConsensusValue = new(big.Int).Add(base, big.NewInt(1)) + higherValueCandidate := testGloasProposal(0, bellatrix.ExecutionAddress{0x02}) + higherValueCandidate.ConsensusValue = new(big.Int).Add(base, big.NewInt(2)) + service, err := best.New(ctx, + best.WithLogLevel(zerolog.Disabled), + best.WithClientMonitor(nullmetrics.New()), + best.WithProcessConcurrency(2), + best.WithChainTimeService(chainTime), + best.WithSpecProvider(specProvider), + best.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "lower": &testEPBSProposalProvider{proposal: lowerValueCandidate}, + "higher": &testEPBSProposalProvider{proposal: higherValueCandidate, delay: 10 * time.Millisecond}, + }), + best.WithTimeout(time.Second), + best.WithBlockRootToSlotCache(cacheSvc.(cache.BlockRootToSlotProvider)), + ) + require.NoError(t, err) + + response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) + require.NoError(t, err) + require.Same(t, higherValueCandidate, response.Data) +} + func TestEPBSProposalRejectsNilData(t *testing.T) { ctx := context.Background() specProvider := mock.NewSpecProvider() @@ -568,6 +643,7 @@ func TestEPBSProposalStartsProvidersWhileGraffitiClientLookupIsSlow(t *testing.T type testEPBSProposalProvider struct { proposal *api.VersionedEPBSProposal err error + delay time.Duration waitForCancellation bool } @@ -577,11 +653,12 @@ type clientGraffitiEPBSProposalProvider struct { graffiti chan [32]byte } -func (*clientGraffitiEPBSProposalProvider) Proposal( - _ context.Context, - _ *api.ProposalOpts, +func (p *clientGraffitiEPBSProposalProvider) Proposal(ctx context.Context, + opts *api.ProposalOpts, ) (*api.Response[*api.VersionedProposal], error) { - return nil, nil + p.graffiti <- opts.Graffiti + + return mock.NewProposalProvider().Proposal(ctx, opts) } func (p *clientGraffitiEPBSProposalProvider) EPBSProposal( @@ -685,6 +762,9 @@ func (p *testEPBSProposalProvider) EPBSProposal(ctx context.Context, *api.Response[*api.VersionedEPBSProposal], error, ) { + if p.delay != 0 { + time.Sleep(p.delay) + } if p.waitForCancellation { <-ctx.Done() return nil, ctx.Err() From 577d61a2db5c8b0392840d71b440243a988db022 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 12:36:55 +0200 Subject: [PATCH 24/30] perf: cache hard fork schedule lookups Build an immutable schedule from all *_FORK_EPOCH spec values and serve known lookups from an atomic snapshot. Coalesce refreshes for newly advertised forks, preserve last-known-good values, reject activated-fork rewrites, and publish only effective changes. BenchmarkHardForkEpoch (Apple M4, 10 samples): - before: 12.31 ns/op, 8 B/op, 1 alloc/op - after: 5.24 ns/op, 0 B/op, 0 allocs/op - result: 2.35x faster, 57% lower latency Validation: - gosilent test ./... (919 tests) - gosilent test -race ./services/chaintime/standard - go build ./... - gopls check on all four changed files - git diff --check The custom lint still reports 50 pre-existing findings outside this change; none are in the changed files. --- services/chaintime/standard/service.go | 170 ++++++++-- .../standard/service_benchmark_test.go | 40 +++ .../standard/service_internal_test.go | 315 ++++++++++++++++++ services/chaintime/standard/service_test.go | 214 ++++++++++++ 4 files changed, 718 insertions(+), 21 deletions(-) create mode 100644 services/chaintime/standard/service_benchmark_test.go create mode 100644 services/chaintime/standard/service_internal_test.go diff --git a/services/chaintime/standard/service.go b/services/chaintime/standard/service.go index 99cc4ecc..19f922bc 100644 --- a/services/chaintime/standard/service.go +++ b/services/chaintime/standard/service.go @@ -16,6 +16,9 @@ package standard import ( "context" "fmt" + "maps" + "strings" + "sync/atomic" "time" client "github.com/attestantio/go-eth2-client" @@ -26,13 +29,30 @@ import ( zerologger "github.com/rs/zerolog/log" ) +// forkSchedule is immutable after publication through Service.forkSchedule. +type forkSchedule struct { + epochs map[string]phase0.Epoch + malformed map[string]struct{} +} + +type forkScheduleRefresh struct { + err error + done chan struct{} +} + +type forkScheduleGeneration struct { + refresh atomic.Pointer[forkScheduleRefresh] +} + // Service provides chain time services. type Service struct { - log zerolog.Logger - genesisTime time.Time - slotDuration time.Duration - slotsPerEpoch uint64 - specProvider client.SpecProvider + log zerolog.Logger + specProvider client.SpecProvider + genesisTime time.Time + slotDuration time.Duration + slotsPerEpoch uint64 + forkSchedule atomic.Pointer[forkSchedule] + forkScheduleGeneration atomic.Pointer[forkScheduleGeneration] } // New creates a new controller. @@ -88,6 +108,9 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { slotsPerEpoch: slotsPerEpoch, specProvider: parameters.specProvider, } + forkSchedule := forkScheduleFromSpec(spec) + s.forkSchedule.Store(&forkSchedule) + s.forkScheduleGeneration.Store(&forkScheduleGeneration{}) return s, nil } @@ -135,30 +158,135 @@ func (s *Service) FirstSlotOfEpoch(epoch phase0.Epoch) phase0.Slot { // HardForkEpoch returns the activation epoch of the specified hard fork or far future epoch if missing. func (s *Service) HardForkEpoch(ctx context.Context, hardForkName string) phase0.Epoch { - forkEpoch, err := s.getHardForkEpoch(ctx, hardForkName) - if err != nil { + forkSchedule := s.forkSchedule.Load() + if forkEpoch, exists := forkSchedule.epochs[hardForkName]; exists { + return forkEpoch + } + if _, exists := forkSchedule.malformed[hardForkName]; exists { + s.log.Error().Err(fmt.Errorf("%s is not a uint64", hardForkName)).Msg("Failed to obtain hard fork") + return 0xffffffffffffffff + } + + generation := s.forkScheduleGeneration.Load() + forkSchedule = s.forkSchedule.Load() + if forkEpoch, exists := forkSchedule.epochs[hardForkName]; exists { + return forkEpoch + } + if _, exists := forkSchedule.malformed[hardForkName]; exists { + s.log.Error().Err(fmt.Errorf("%s is not a uint64", hardForkName)).Msg("Failed to obtain hard fork") + return 0xffffffffffffffff + } + if err := s.refreshForkSchedule(ctx, generation); err != nil { s.log.Error().Err(err).Msg("Failed to obtain hard fork") return 0xffffffffffffffff } - return forkEpoch + forkSchedule = s.forkSchedule.Load() + if forkEpoch, exists := forkSchedule.epochs[hardForkName]; exists { + return forkEpoch + } + if _, exists := forkSchedule.malformed[hardForkName]; exists { + s.log.Error().Err(fmt.Errorf("%s is not a uint64", hardForkName)).Msg("Failed to obtain hard fork") + return 0xffffffffffffffff + } + + s.log.Error().Err(fmt.Errorf("%s version not known by chain", hardForkName)).Msg("Failed to obtain hard fork") + return 0xffffffffffffffff } -func (s *Service) getHardForkEpoch(ctx context.Context, hardForkName string) (phase0.Epoch, error) { - // Fetch the fork version. - specResponse, err := s.specProvider.Spec(ctx, &api.SpecOpts{}) - if err != nil { - return 0, errors.Wrap(err, "failed to obtain spec") +func forkScheduleFromSpec(spec map[string]any) forkSchedule { + res := forkSchedule{ + epochs: make(map[string]phase0.Epoch), + malformed: make(map[string]struct{}), + } + for name, value := range spec { + if !strings.HasSuffix(name, "_FORK_EPOCH") { + continue + } + epoch, isEpoch := value.(uint64) + if !isEpoch { + res.malformed[name] = struct{}{} + continue + } + res.epochs[name] = phase0.Epoch(epoch) } - spec := specResponse.Data - tmp, exists := spec[hardForkName] - if !exists { - return 0, fmt.Errorf("%s version not known by chain", hardForkName) + return res +} + +func (s *Service) refreshForkSchedule(ctx context.Context, generation *forkScheduleGeneration) error { + if generation == nil { + generation = &forkScheduleGeneration{} + if !s.forkScheduleGeneration.CompareAndSwap(nil, generation) { + generation = s.forkScheduleGeneration.Load() + } + } + + refresh := generation.refresh.Load() + leader := false + if refresh == nil { + candidate := &forkScheduleRefresh{done: make(chan struct{})} + if generation.refresh.CompareAndSwap(nil, candidate) { + refresh = candidate + leader = true + } else { + refresh = generation.refresh.Load() + } } - epoch, isEpoch := tmp.(uint64) - if !isEpoch { - return 0, fmt.Errorf("%s is not a uint64", hardForkName) + if !leader { + select { + case <-refresh.done: + return refresh.err + case <-ctx.Done(): + return ctx.Err() + } } + specResponse, err := s.specProvider.Spec(ctx, &api.SpecOpts{}) + if err != nil { + err = errors.Wrap(err, "failed to obtain spec") + } else { + current := s.forkSchedule.Load() + incoming := forkScheduleFromSpec(specResponse.Data) + var next *forkSchedule + cloneCurrent := func() { + if next == nil { + next = &forkSchedule{ + epochs: maps.Clone(current.epochs), + malformed: maps.Clone(current.malformed), + } + } + } + for name, epoch := range incoming.epochs { + if currentEpoch, exists := current.epochs[name]; exists { + if currentEpoch == epoch { + continue + } + now := s.CurrentEpoch() + if currentEpoch <= now || epoch <= now { + continue + } + } + cloneCurrent() + next.epochs[name] = epoch + delete(next.malformed, name) + } + for name := range incoming.malformed { + if _, exists := current.epochs[name]; exists { + continue + } + if _, exists := current.malformed[name]; exists { + continue + } + cloneCurrent() + next.malformed[name] = struct{}{} + } + if next != nil { + s.forkSchedule.Store(next) + } + } + + refresh.err = err + s.forkScheduleGeneration.CompareAndSwap(generation, &forkScheduleGeneration{}) + close(refresh.done) - return phase0.Epoch(epoch), nil + return err } diff --git a/services/chaintime/standard/service_benchmark_test.go b/services/chaintime/standard/service_benchmark_test.go new file mode 100644 index 00000000..5790d120 --- /dev/null +++ b/services/chaintime/standard/service_benchmark_test.go @@ -0,0 +1,40 @@ +// 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 standard_test + +import ( + "context" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +var benchmarkForkEpoch phase0.Epoch + +func BenchmarkHardForkEpoch(b *testing.B) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": uint64(1_000_000), + }) + service := createMutableSpecService(b, specProvider) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + benchmarkForkEpoch = service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") + } +} diff --git a/services/chaintime/standard/service_internal_test.go b/services/chaintime/standard/service_internal_test.go new file mode 100644 index 00000000..ac8f7312 --- /dev/null +++ b/services/chaintime/standard/service_internal_test.go @@ -0,0 +1,315 @@ +// 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 standard + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +type internalSpecProvider struct { + spec map[string]any + err error + calls atomic.Uint64 +} + +func (s *internalSpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map[string]any], error) { + s.calls.Add(1) + if s.err != nil { + return nil, s.err + } + return &api.Response[map[string]any]{Data: s.spec}, nil +} + +type blockingInternalSpecProvider struct { + entered chan struct{} + release chan struct{} +} + +func (s *blockingInternalSpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map[string]any], error) { + close(s.entered) + <-s.release + + return &api.Response[map[string]any]{Data: make(map[string]any)}, nil +} + +func TestRefreshForkScheduleWaiterHonoursCancellation(t *testing.T) { + ctx := context.Background() + provider := &blockingInternalSpecProvider{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + schedule := forkSchedule{ + epochs: make(map[string]phase0.Epoch), + malformed: make(map[string]struct{}), + } + service := &Service{ + genesisTime: time.Now(), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: provider, + } + service.forkSchedule.Store(&schedule) + + leaderDone := make(chan error, 1) + go func() { + leaderDone <- service.refreshForkSchedule(ctx, nil) + }() + <-provider.entered + t.Cleanup(func() { + close(provider.release) + require.NoError(t, <-leaderDone) + }) + + waiterCtx, cancel := context.WithCancel(ctx) + cancel() + waiterDone := make(chan error, 1) + go func() { + waiterDone <- service.refreshForkSchedule(waiterCtx, nil) + }() + + select { + case err := <-waiterDone: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("canceled refresh waiter remained blocked") + } +} + +func TestRefreshForkScheduleSharesCompletedGeneration(t *testing.T) { + ctx := context.Background() + provider := &internalSpecProvider{spec: make(map[string]any)} + schedule := forkSchedule{ + epochs: make(map[string]phase0.Epoch), + malformed: make(map[string]struct{}), + } + generation := &forkScheduleGeneration{} + service := &Service{ + genesisTime: time.Now(), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: provider, + } + service.forkSchedule.Store(&schedule) + service.forkScheduleGeneration.Store(generation) + + require.NoError(t, service.refreshForkSchedule(ctx, generation)) + require.NoError(t, service.refreshForkSchedule(ctx, generation)) + require.Equal(t, uint64(1), provider.calls.Load()) +} + +func TestRefreshForkScheduleCoalescesGeneration(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + spec map[string]any + providerErr error + expected map[string]phase0.Epoch + err string + }{ + { + name: "Missing", + spec: make(map[string]any), + expected: make(map[string]phase0.Epoch), + }, + { + name: "Discovery", + spec: map[string]any{ + "FUTURE_FORK_EPOCH": uint64(2048), + }, + expected: map[string]phase0.Epoch{ + "FUTURE_FORK_EPOCH": 2048, + }, + }, + { + name: "Failure", + spec: make(map[string]any), + providerErr: errors.New("spec unavailable"), + expected: make(map[string]phase0.Epoch), + err: "failed to obtain spec: spec unavailable", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider := &internalSpecProvider{spec: test.spec, err: test.providerErr} + schedule := forkSchedule{ + epochs: make(map[string]phase0.Epoch), + malformed: make(map[string]struct{}), + } + generation := &forkScheduleGeneration{} + service := &Service{ + genesisTime: time.Now(), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: provider, + } + service.forkSchedule.Store(&schedule) + service.forkScheduleGeneration.Store(generation) + + const readers = 16 + results := make(chan error, readers) + for range readers { + go func() { + results <- service.refreshForkSchedule(ctx, generation) + }() + } + for range readers { + err := <-results + if test.err == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.err) + } + } + + require.Equal(t, uint64(1), provider.calls.Load()) + require.Equal(t, test.expected, service.forkSchedule.Load().epochs) + }) + } +} + +func TestRefreshForkSchedule(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + spec map[string]any + expected map[string]phase0.Epoch + same bool + }{ + { + name: "Unchanged", + spec: map[string]any{ + "GLOAS_FORK_EPOCH": uint64(2048), + }, + expected: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": phase0.Epoch(2048), + }, + same: true, + }, + { + name: "Omitted", + spec: make(map[string]any), + expected: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": phase0.Epoch(2048), + }, + same: true, + }, + { + name: "Added", + spec: map[string]any{ + "GLOAS_FORK_EPOCH": uint64(2048), + "FUTURE_FORK_EPOCH": uint64(4096), + }, + expected: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": phase0.Epoch(2048), + "FUTURE_FORK_EPOCH": phase0.Epoch(4096), + }, + }, + { + name: "Changed", + spec: map[string]any{ + "GLOAS_FORK_EPOCH": uint64(4096), + }, + expected: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": phase0.Epoch(4096), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + schedule := forkSchedule{ + epochs: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": phase0.Epoch(2048), + }, + malformed: make(map[string]struct{}), + } + service := &Service{ + genesisTime: time.Now(), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: &internalSpecProvider{spec: test.spec}, + } + service.forkSchedule.Store(&schedule) + before := service.forkSchedule.Load() + + require.NoError(t, service.refreshForkSchedule(ctx, nil)) + after := service.forkSchedule.Load() + if test.same { + require.Same(t, before, after) + } else { + require.NotSame(t, before, after) + } + require.Equal(t, test.expected, after.epochs) + }) + } +} + +func TestRefreshForkScheduleRejectsActivatedForkChange(t *testing.T) { + ctx := context.Background() + schedule := forkSchedule{ + epochs: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": 1, + }, + malformed: make(map[string]struct{}), + } + service := &Service{ + genesisTime: time.Now().Add(-100 * 32 * 12 * time.Second), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: &internalSpecProvider{spec: map[string]any{ + "GLOAS_FORK_EPOCH": uint64(4096), + }}, + } + service.forkSchedule.Store(&schedule) + before := service.forkSchedule.Load() + + require.NoError(t, service.refreshForkSchedule(ctx, nil)) + after := service.forkSchedule.Load() + require.Same(t, before, after) + require.Equal(t, phase0.Epoch(1), after.epochs["GLOAS_FORK_EPOCH"]) +} + +func TestRefreshForkScheduleRejectsRollback(t *testing.T) { + ctx := context.Background() + schedule := forkSchedule{ + epochs: map[string]phase0.Epoch{ + "GLOAS_FORK_EPOCH": 4096, + }, + malformed: make(map[string]struct{}), + } + service := &Service{ + genesisTime: time.Now().Add(-100 * 32 * 12 * time.Second), + slotDuration: 12 * time.Second, + slotsPerEpoch: 32, + specProvider: &internalSpecProvider{spec: map[string]any{ + "GLOAS_FORK_EPOCH": uint64(1), + }}, + } + service.forkSchedule.Store(&schedule) + before := service.forkSchedule.Load() + + require.NoError(t, service.refreshForkSchedule(ctx, nil)) + after := service.forkSchedule.Load() + require.Same(t, before, after) + require.Equal(t, phase0.Epoch(4096), after.epochs["GLOAS_FORK_EPOCH"]) +} diff --git a/services/chaintime/standard/service_test.go b/services/chaintime/standard/service_test.go index d03d4c6d..7e00fbc0 100644 --- a/services/chaintime/standard/service_test.go +++ b/services/chaintime/standard/service_test.go @@ -15,18 +15,88 @@ package standard_test import ( "context" + "errors" + "sync" + "sync/atomic" "testing" "time" + "github.com/attestantio/go-eth2-client/api" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" "github.com/attestantio/vouch/services/chaintime" "github.com/attestantio/vouch/services/chaintime/standard" + testlogger "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" + zerologger "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" "gotest.tools/assert" ) +type mutableSpecProvider struct { + mu sync.RWMutex + response *api.Response[map[string]any] + err error + calls atomic.Uint64 + entered chan struct{} + release chan struct{} +} + +func (s *mutableSpecProvider) Spec(_ context.Context, _ *api.SpecOpts) (*api.Response[map[string]any], error) { + s.calls.Add(1) + s.mu.RLock() + response := s.response + err := s.err + entered := s.entered + release := s.release + s.mu.RUnlock() + if entered != nil { + select { + case entered <- struct{}{}: + default: + } + } + if release != nil { + <-release + } + if err != nil { + return nil, err + } + + return response, nil +} + +func newMutableSpecProvider(spec map[string]any) *mutableSpecProvider { + return &mutableSpecProvider{ + response: &api.Response[map[string]any]{Data: spec}, + } +} + +func (s *mutableSpecProvider) setSpec(spec map[string]any) { + s.mu.Lock() + s.response = &api.Response[map[string]any]{Data: spec} + s.mu.Unlock() +} + +func (s *mutableSpecProvider) setError(err error) { + s.mu.Lock() + s.err = err + s.mu.Unlock() +} + +func createMutableSpecService(t testing.TB, specProvider *mutableSpecProvider) chaintime.Service { + t.Helper() + + service, err := standard.New(context.Background(), + standard.WithLogLevel(zerolog.Disabled), + standard.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standard.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + + return service +} + func TestService(t *testing.T) { genesisTime := time.Now() mockGenesisProvider := mock.NewGenesisProvider(genesisTime) @@ -227,3 +297,147 @@ func TestFirstSlotOfEpoch(t *testing.T) { }) } } + +func TestHardForkEpochUsesConstructionSchedule(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": uint64(2048), + }) + service := createMutableSpecService(t, specProvider) + + specProvider.setError(errors.New("spec unavailable")) + + require.Equal(t, phase0.Epoch(2048), service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")) +} + +func TestHardForkEpochRetainsDiscoveredFork(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + }) + service := createMutableSpecService(t, specProvider) + + specProvider.setSpec(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "FUTURE_FORK_EPOCH": uint64(2048), + }) + require.Equal(t, phase0.Epoch(2048), service.HardForkEpoch(ctx, "FUTURE_FORK_EPOCH")) + + specProvider.setError(errors.New("spec unavailable")) + require.Equal(t, phase0.Epoch(2048), service.HardForkEpoch(ctx, "FUTURE_FORK_EPOCH")) +} + +func TestHardForkEpochRecognisesMalformedConstructionValue(t *testing.T) { + ctx := context.Background() + oldLogger := zerologger.Logger + oldLogLevel := zerolog.GlobalLevel() + t.Cleanup(func() { + zerologger.Logger = oldLogger + zerolog.SetGlobalLevel(oldLogLevel) + }) + logCapture := testlogger.NewLogCapture() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": "2048", + }) + service, err := standard.New(ctx, + standard.WithLogLevel(zerolog.TraceLevel), + standard.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standard.WithSpecProvider(specProvider), + ) + require.NoError(t, err) + + require.Equal(t, phase0.Epoch(^uint64(0)), service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")) + require.Equal(t, uint64(1), specProvider.calls.Load()) + require.True(t, logCapture.HasLog(map[string]any{ + "error": "GLOAS_FORK_EPOCH is not a uint64", + })) +} + +func TestHardForkEpochRetainsMalformedRefreshedValue(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + }) + service := createMutableSpecService(t, specProvider) + + specProvider.setSpec(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": "2048", + }) + require.Equal(t, phase0.Epoch(^uint64(0)), service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")) + + specProvider.setError(errors.New("spec unavailable")) + require.Equal(t, phase0.Epoch(^uint64(0)), service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")) + require.Equal(t, uint64(2), specProvider.calls.Load()) +} + +func TestHardForkEpochRetainsValidValueWhenRefreshIsMalformed(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": uint64(2048), + }) + service := createMutableSpecService(t, specProvider) + + specProvider.setSpec(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": "2048", + }) + require.Equal(t, phase0.Epoch(^uint64(0)), service.HardForkEpoch(ctx, "MISSING_FORK_EPOCH")) + require.Equal(t, phase0.Epoch(2048), service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH")) + require.Equal(t, uint64(2), specProvider.calls.Load()) +} + +func TestHardForkEpochReturnsFarFutureWhenUnavailable(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + }) + service := createMutableSpecService(t, specProvider) + specProvider.setError(errors.New("spec unavailable")) + + require.Equal(t, phase0.Epoch(^uint64(0)), service.HardForkEpoch(ctx, "MISSING_FORK_EPOCH")) +} + +func TestHardForkEpochReadersContinueDuringRefresh(t *testing.T) { + ctx := context.Background() + specProvider := newMutableSpecProvider(map[string]any{ + "SECONDS_PER_SLOT": 12 * time.Second, + "SLOTS_PER_EPOCH": uint64(32), + "GLOAS_FORK_EPOCH": uint64(2048), + }) + service := createMutableSpecService(t, specProvider) + + specProvider.entered = make(chan struct{}, 1) + specProvider.release = make(chan struct{}) + refreshed := make(chan phase0.Epoch, 1) + go func() { + refreshed <- service.HardForkEpoch(ctx, "MISSING_FORK_EPOCH") + }() + <-specProvider.entered + + known := make(chan phase0.Epoch, 1) + go func() { + known <- service.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") + }() + select { + case epoch := <-known: + require.Equal(t, phase0.Epoch(2048), epoch) + case <-time.After(time.Second): + t.Fatal("known fork lookup blocked during refresh") + } + + close(specProvider.release) + require.Equal(t, phase0.Epoch(^uint64(0)), <-refreshed) +} From 6de010bec1cdd739a6279becbebd001d8fe5c9b9 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 15:39:57 +0200 Subject: [PATCH 25/30] refactor: simplify fork schedule refresh Separate immutable schedule reconciliation from refresh coordination and document the non-obvious generation and activation invariants. Reduce refreshForkSchedule cyclomatic complexity from 19 to 10 while preserving the allocation-free hard-fork lookup path. --- services/chaintime/standard/service.go | 80 +++++++++++++++----------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/services/chaintime/standard/service.go b/services/chaintime/standard/service.go index 19f922bc..e8e687a4 100644 --- a/services/chaintime/standard/service.go +++ b/services/chaintime/standard/service.go @@ -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 @@ -168,6 +168,7 @@ func (s *Service) HardForkEpoch(ctx context.Context, hardForkName string) phase0 } generation := s.forkScheduleGeneration.Load() + // A concurrent refresh may have published the requested fork before we captured its generation. forkSchedule = s.forkSchedule.Load() if forkEpoch, exists := forkSchedule.epochs[hardForkName]; exists { return forkEpoch @@ -214,6 +215,7 @@ func forkScheduleFromSpec(spec map[string]any) forkSchedule { } func (s *Service) refreshForkSchedule(ctx context.Context, generation *forkScheduleGeneration) error { + // Each generation admits one provider request; other callers reuse its result. if generation == nil { generation = &forkScheduleGeneration{} if !s.forkScheduleGeneration.CompareAndSwap(nil, generation) { @@ -246,47 +248,59 @@ func (s *Service) refreshForkSchedule(ctx context.Context, generation *forkSched } else { current := s.forkSchedule.Load() incoming := forkScheduleFromSpec(specResponse.Data) - var next *forkSchedule - cloneCurrent := func() { - if next == nil { - next = &forkSchedule{ - epochs: maps.Clone(current.epochs), - malformed: maps.Clone(current.malformed), - } - } + next := mergeForkSchedule(current, incoming, s.CurrentEpoch()) + if next != nil { + s.forkSchedule.Store(next) } - for name, epoch := range incoming.epochs { - if currentEpoch, exists := current.epochs[name]; exists { - if currentEpoch == epoch { - continue - } - now := s.CurrentEpoch() - if currentEpoch <= now || epoch <= now { - continue - } + } + + refresh.err = err + // Advance the generation before waking waiters so a later miss can refresh again. + s.forkScheduleGeneration.CompareAndSwap(generation, &forkScheduleGeneration{}) + close(refresh.done) + + return err +} + +func mergeForkSchedule(current *forkSchedule, + incoming forkSchedule, + currentEpoch phase0.Epoch, +) *forkSchedule { + var next *forkSchedule + cloneCurrent := func() { + if next == nil { + next = &forkSchedule{ + epochs: maps.Clone(current.epochs), + malformed: maps.Clone(current.malformed), } - cloneCurrent() - next.epochs[name] = epoch - delete(next.malformed, name) } - for name := range incoming.malformed { - if _, exists := current.epochs[name]; exists { + } + + for name, epoch := range incoming.epochs { + if existingEpoch, exists := current.epochs[name]; exists { + if existingEpoch == epoch { continue } - if _, exists := current.malformed[name]; exists { + // Once either schedule activates a fork, retain the last-known-good boundary. + if existingEpoch <= currentEpoch || epoch <= currentEpoch { continue } - cloneCurrent() - next.malformed[name] = struct{}{} - } - if next != nil { - s.forkSchedule.Store(next) } + cloneCurrent() + next.epochs[name] = epoch + delete(next.malformed, name) } - refresh.err = err - s.forkScheduleGeneration.CompareAndSwap(generation, &forkScheduleGeneration{}) - close(refresh.done) + for name := range incoming.malformed { + if _, exists := current.epochs[name]; exists { + continue + } + if _, exists := current.malformed[name]; exists { + continue + } + cloneCurrent() + next.malformed[name] = struct{}{} + } - return err + return next } From 96da104581933b6b907ca0f8f4b9a00632ad9b7f Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 15:54:27 +0200 Subject: [PATCH 26/30] fix: validate self-built Gloas proposals --- .../standard/gloas_test.go | 63 +++++++++++++++++-- .../beaconblockproposer/standard/propose.go | 7 ++- .../best/beaconblockproposal.go | 19 +----- .../best/epbsproposal_test.go | 7 +-- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index f0f7236e..bfd32035 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -16,6 +16,7 @@ package standard_test import ( "context" "errors" + "math" "testing" "time" @@ -56,6 +57,7 @@ func TestProposeGloas(t *testing.T) { builderBoostFactor uint64 proposerIndexMismatch bool builderIndexMismatch bool + foreignBuilderIndex bool envelopeRootMismatch bool envelopePayloadMissing bool executionPayloadBidMissing bool @@ -95,6 +97,12 @@ func TestProposeGloas(t *testing.T) { builderIndexMismatch: true, err: "failed to propose block: ePBS execution payload envelope is for incorrect builder index", }, + { + name: "ForeignBuilderIndex", + executionPayloadIncluded: true, + foreignBuilderIndex: true, + err: "failed to propose block: ePBS execution payload bid is not self-built", + }, { name: "MissingEnvelopePayload", executionPayloadIncluded: true, @@ -156,6 +164,7 @@ func TestProposeGloas(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -163,7 +172,11 @@ func TestProposeGloas(t *testing.T) { response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot[0] ^= 0xff } if test.builderIndexMismatch { + response.Data.GloasContents.ExecutionPayloadEnvelope.BuilderIndex++ + } + if test.foreignBuilderIndex { response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex++ + response.Data.GloasContents.ExecutionPayloadEnvelope.BuilderIndex = response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex } if test.envelopePayloadMissing { response.Data.GloasContents.ExecutionPayloadEnvelope.Payload = nil @@ -242,7 +255,7 @@ func TestProposeGloas(t *testing.T) { require.Equal(t, 1, proposalSubmitter.calls) require.Equal(t, 3, envelopeSubmitter.calls) } - if test.envelopeRootMismatch || test.builderIndexMismatch || test.envelopePayloadMissing || test.executionPayloadBidMissing { + if test.envelopeRootMismatch || test.builderIndexMismatch || test.foreignBuilderIndex || test.envelopePayloadMissing || test.executionPayloadBidMissing { require.Zero(t, blockSigner.calls) require.Zero(t, envelopeSigner.calls) require.Nil(t, envelopeSubmitter.opts) @@ -298,17 +311,20 @@ func TestProposeGloasProposalSource(t *testing.T) { name string executionPayloadIncluded bool source string + expectedCountDelta float64 err string }{ { name: "SelfBuiltPayload", executionPayloadIncluded: true, source: "local", + expectedCountDelta: 1, }, { name: "ProtocolBuilderPayload", executionPayloadIncluded: false, source: "builder", + expectedCountDelta: 0, err: "failed to propose block: ePBS proposal excludes requested execution payload", }, } @@ -321,7 +337,7 @@ func TestProposeGloasProposalSource(t *testing.T) { ) require.NoError(t, err) proposalSourceCountBefore := beaconBlockProposalSourceCount(t, test.source) - service, duty, blockSigner, envelopeSigner, envelopeSubmitter := newGloasProposerForProposalSource(ctx, t, test.executionPayloadIncluded, monitor) + service, duty, blockSigner, envelopeSigner, envelopeSubmitter, _ := newGloasProposerForProposalSource(ctx, t, test.executionPayloadIncluded, monitor) err = service.Propose(ctx, duty) if test.err != "" { @@ -329,7 +345,7 @@ func TestProposeGloasProposalSource(t *testing.T) { } else { require.NoError(t, err) } - require.Equal(t, proposalSourceCountBefore+1, beaconBlockProposalSourceCount(t, test.source)) + require.Equal(t, proposalSourceCountBefore+test.expectedCountDelta, beaconBlockProposalSourceCount(t, test.source)) if !test.executionPayloadIncluded { require.Zero(t, blockSigner.calls) require.Zero(t, envelopeSigner.calls) @@ -339,6 +355,35 @@ func TestProposeGloasProposalSource(t *testing.T) { } } +func TestProposeGloasProposalSourceSubmissionFailure(t *testing.T) { + ctx := context.Background() + + monitor, err := prometheusmetrics.New(ctx, + prometheusmetrics.WithLogLevel(zerolog.Disabled), + prometheusmetrics.WithAddress("localhost:0"), + ) + require.NoError(t, err) + proposalSourceCountBefore := beaconBlockProposalSourceCount(t, "local") + service, duty, _, _, envelopeSubmitter, proposalSubmitter := newGloasProposerForProposalSource(ctx, t, true, monitor) + proposalSubmitter.err = errors.New("submit failed") + + err = service.Propose(ctx, duty) + require.EqualError(t, err, "failed to propose block: failed to submit proposal: submit failed") + require.Equal(t, proposalSourceCountBefore, beaconBlockProposalSourceCount(t, "local")) + require.Nil(t, envelopeSubmitter.opts) +} + +func setSelfBuildBuilderIndex(t *testing.T, proposal *consensusapi.VersionedEPBSProposal) { + t.Helper() + + proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = gloas.BuilderIndex(math.MaxUint64) + proposal.GloasContents.ExecutionPayloadEnvelope.BuilderIndex = gloas.BuilderIndex(math.MaxUint64) + bodyRoot, err := proposal.GloasContents.Block.Body.HashTreeRoot() + require.NoError(t, err) + convertedBodyRoot := phase0.Root(bodyRoot) + proposal.BeaconBlockBodyRoot = &convertedBodyRoot +} + func newGloasProposerForProposalSource( ctx context.Context, t *testing.T, @@ -349,6 +394,7 @@ func newGloasProposerForProposalSource( *capturingBeaconBlockSigner, *capturingExecutionPayloadEnvelopeSigner, *capturingExecutionPayloadEnvelopeSubmitter, + *capturingProposalSubmitter, ) { t.Helper() @@ -364,6 +410,7 @@ func newGloasProposerForProposalSource( if response.Data.ExecutionPayloadIncluded { response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -396,7 +443,7 @@ func newGloasProposerForProposalSource( duty.SetAccount(&testAccount{}) duty.SetRandaoReveal(phase0.BLSSignature{0x02}) - return service, duty, blockSigner, envelopeSigner, envelopeSubmitter + return service, duty, blockSigner, envelopeSigner, envelopeSubmitter, proposalSubmitter } func beaconBlockProposalSourceCount(t *testing.T, source string) float64 { @@ -446,6 +493,7 @@ func TestProposeGloasSignsRetainedBodyRoot(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) block := response.Data.GloasContents.Block generatedRoot, err := block.Body.HashTreeRoot() @@ -532,6 +580,7 @@ func TestProposeGloasMissingBodyRootFails(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -591,6 +640,7 @@ func TestProposeGloasStartsBothSignaturesBeforePublication(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -679,6 +729,7 @@ func TestProposeGloasCancelsPeerSigningAfterFailure(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -768,6 +819,7 @@ func TestProposeGloasCancelsBlockedBlockSigningAfterEnvelopeFailure(t *testing.T } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildBuilderIndex(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -881,13 +933,14 @@ func TestProposePreGloas(t *testing.T) { type capturingProposalSubmitter struct { proposal *consensusapi.VersionedSignedProposal calls int + err error } func (s *capturingProposalSubmitter) SubmitProposal(_ context.Context, proposal *consensusapi.VersionedSignedProposal) error { s.calls++ s.proposal = proposal - return nil + return s.err } var _ submitter.ProposalSubmitter = (*capturingProposalSubmitter)(nil) diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index d750127c..25bca026 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strings" "time" @@ -260,10 +261,8 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, } proposal := proposalResponse.Data if !proposal.ExecutionPayloadIncluded { - monitorBeaconBlockProposalSource("builder") return errors.New("ePBS proposal excludes requested execution payload") } - monitorBeaconBlockProposalSource("local") if err := s.confirmEPBSProposalData(ctx, proposal, duty); err != nil { return err @@ -307,6 +306,7 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { return errors.Wrap(err, "failed to submit proposal") } + monitorBeaconBlockProposalSource("local") envelopeSubmissionOpts := &api.SubmitExecutionPayloadEnvelopeOpts{ SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ @@ -354,6 +354,9 @@ func (*Service) epbsProposalEnvelope(proposal *api.VersionedEPBSProposal) (*gloa if proposal.GloasContents == nil || proposal.GloasContents.Block == nil || proposal.GloasContents.Block.Body == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message == nil { return nil, phase0.Root{}, errors.New("ePBS proposal has no execution payload bid") } + if proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex != gloas.BuilderIndex(math.MaxUint64) { + return nil, phase0.Root{}, errors.New("ePBS execution payload bid is not self-built") + } if envelope.BuilderIndex != proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex { return nil, phase0.Root{}, errors.New("ePBS execution payload envelope is for incorrect builder index") } diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index 24137f0f..fce11811 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -185,30 +185,13 @@ func (s *Service) considerEPBSProposal(opts *api.EPBSProposalOpts, return bestProposal, bestProvider } - if bestProposal == nil || s.scoreEPBSProposal(response.proposal).Cmp(s.scoreEPBSProposal(bestProposal)) > 0 { + if bestProposal == nil || response.proposal.Value().Cmp(bestProposal.Value()) > 0 { return response.proposal, response.provider } return bestProposal, bestProvider } -func (s *Service) scoreEPBSProposal(proposal *api.VersionedEPBSProposal) *big.Rat { - score := new(big.Rat).SetInt(proposal.Value()) - if proposal.ConsensusValue == nil && proposal.ExecutionValue == nil { - s.log.Warn().Msg("ePBS proposal has no value headers; scoring execution payload gas only") - } - if proposal.ExecutionPayloadIncluded && proposal.GloasContents != nil && proposal.GloasContents.ExecutionPayloadEnvelope != nil && proposal.GloasContents.ExecutionPayloadEnvelope.Payload != nil { - payload := proposal.GloasContents.ExecutionPayloadEnvelope.Payload - if factor := new(big.Rat).SetFloat64(s.executionPayloadFactor); factor != nil { - gasUsed := new(big.Int).SetUint64(payload.GasUsed) - gasUsed.Add(gasUsed, new(big.Int).SetUint64(payload.BlobGasUsed)) - score.Add(score, new(big.Rat).Mul(new(big.Rat).SetInt(gasUsed), factor)) - } - } - - return score -} - type beaconBlockEPBSResponse struct { provider string proposal *api.VersionedEPBSProposal diff --git a/strategies/beaconblockproposal/best/epbsproposal_test.go b/strategies/beaconblockproposal/best/epbsproposal_test.go index 95f2b451..a2017963 100644 --- a/strategies/beaconblockproposal/best/epbsproposal_test.go +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -35,7 +35,6 @@ import ( standardchaintime "github.com/attestantio/vouch/services/chaintime/standard" nullmetrics "github.com/attestantio/vouch/services/metrics/null" "github.com/attestantio/vouch/strategies/beaconblockproposal/best" - "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -244,9 +243,8 @@ func TestEPBSProposalRejectsZeroFeeRecipientWithoutPayload(t *testing.T) { require.Same(t, validCandidate, response.Data) } -func TestEPBSProposalWeightsExecutionPayloadGas(t *testing.T) { +func TestEPBSProposalDoesNotWeightExecutionPayloadGas(t *testing.T) { ctx := context.Background() - capture := logger.NewLogCapture() specProvider := mock.NewSpecProvider() chainTime, err := standardchaintime.New(ctx, standardchaintime.WithLogLevel(zerolog.Disabled), @@ -279,8 +277,7 @@ func TestEPBSProposalWeightsExecutionPayloadGas(t *testing.T) { response, err := service.EPBSProposal(ctx, &api.EPBSProposalOpts{}) require.NoError(t, err) - require.Same(t, executionCandidate, response.Data) - capture.AssertHasEntry(t, "ePBS proposal has no value headers; scoring execution payload gas only") + require.Same(t, consensusCandidate, response.Data) } func TestEPBSProposalComparesLargeValuesExactly(t *testing.T) { From 314e7d0da3b82bc06ea5cf2cf7eef85125ce22c0 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 16:01:25 +0200 Subject: [PATCH 27/30] fix: satisfy Gloas proposal analysis --- services/beaconblockproposer/standard/gloas_test.go | 1 + services/beaconblockproposer/standard/propose.go | 1 + strategies/beaconblockproposal/best/beaconblockproposal.go | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index bfd32035..37964870 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -47,6 +47,7 @@ import ( e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" ) +// skipcq: GO-R1005 func TestProposeGloas(t *testing.T) { ctx := context.Background() diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 25bca026..6521d0fa 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -229,6 +229,7 @@ func (s *Service) unblindingProviders(auctionResults *blockauctioneer.Results) ( } // proposeEPBSBlock proposes a Gloas block. +// skipcq: GO-R1005 func (s *Service) proposeEPBSBlock(ctx context.Context, duty *beaconblockproposer.Duty, graffiti [32]byte, diff --git a/strategies/beaconblockproposal/best/beaconblockproposal.go b/strategies/beaconblockproposal/best/beaconblockproposal.go index fce11811..7530b2fe 100644 --- a/strategies/beaconblockproposal/best/beaconblockproposal.go +++ b/strategies/beaconblockproposal/best/beaconblockproposal.go @@ -87,7 +87,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - bestProposal, bestProvider = s.considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -129,7 +129,7 @@ func (s *Service) EPBSProposal(ctx context.Context, Int("errored", errored). Int("timed_out", timedOut). Msg("Response received") - bestProposal, bestProvider = s.considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) case err := <-errCh: errored++ log.Debug(). @@ -173,7 +173,7 @@ func (s *Service) EPBSProposal(ctx context.Context, // considerEPBSProposal updates the best proposal seen so far, ignoring proposals that lack a // requested execution payload. -func (s *Service) considerEPBSProposal(opts *api.EPBSProposalOpts, +func considerEPBSProposal(opts *api.EPBSProposalOpts, response *beaconBlockEPBSResponse, bestProposal *api.VersionedEPBSProposal, bestProvider string, From a7d8a6b33d3bb20c5b1eb318c2ce4c12e5a99457 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 16:45:48 +0200 Subject: [PATCH 28/30] fix: count completed Gloas proposal sources --- services/beaconblockproposer/standard/gloas_test.go | 10 ++++++++++ services/beaconblockproposer/standard/propose.go | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 37964870..31d718c8 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -311,6 +311,7 @@ func TestProposeGloasProposalSource(t *testing.T) { tests := []struct { name string executionPayloadIncluded bool + envelopeSubmissionErr error source string expectedCountDelta float64 err string @@ -328,6 +329,14 @@ func TestProposeGloasProposalSource(t *testing.T) { expectedCountDelta: 0, err: "failed to propose block: ePBS proposal excludes requested execution payload", }, + { + name: "EnvelopeSubmissionFailure", + executionPayloadIncluded: true, + envelopeSubmissionErr: errors.New("submit failed"), + source: "local", + expectedCountDelta: 0, + err: "failed to propose block: failed to submit execution payload envelope after block publication: submit failed", + }, } for _, test := range tests { @@ -339,6 +348,7 @@ func TestProposeGloasProposalSource(t *testing.T) { require.NoError(t, err) proposalSourceCountBefore := beaconBlockProposalSourceCount(t, test.source) service, duty, blockSigner, envelopeSigner, envelopeSubmitter, _ := newGloasProposerForProposalSource(ctx, t, test.executionPayloadIncluded, monitor) + envelopeSubmitter.err = test.envelopeSubmissionErr err = service.Propose(ctx, duty) if test.err != "" { diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 6521d0fa..253ed315 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -307,7 +307,6 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { return errors.Wrap(err, "failed to submit proposal") } - monitorBeaconBlockProposalSource("local") envelopeSubmissionOpts := &api.SubmitExecutionPayloadEnvelopeOpts{ SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ @@ -338,6 +337,7 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, if envelopeSubmissionErr != nil { return errors.Wrap(envelopeSubmissionErr, "failed to submit execution payload envelope after block publication") } + monitorBeaconBlockProposalSource("local") return nil } From 918f6a2a67e5deb4dcfdd31457d712bcc3e7d7b3 Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 14 Aug 2026 18:20:31 +0200 Subject: [PATCH 29/30] Add gloas changelog section Start a gloas section capturing the self-build ePBS proposal path, the strategy and startup changes it brings, the custom-spec-support setting, and the supporting chaintime, cache, metrics, and dependency updates. This section accumulates gloas entries until Glamsterdam support is complete, at which point it is folded into a proper release section. --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea2a167..2999cab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +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 + - 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 From e809c6ebfb271102a6b927acd866a51851a14a4d Mon Sep 17 00:00:00 2001 From: AntiD2ta Date: Fri, 21 Aug 2026 10:52:22 +0200 Subject: [PATCH 30/30] Check the payload fee recipient before signing a Gloas envelope A self-built bid pays nothing. The spec requires bid.value to be zero for BUILDER_INDEX_SELF_BUILD and records no builder payment for it, so the bid fee recipient that the proposal strategies guard is not the one collecting the slot's revenue. That is the execution payload's own fee recipient, carried in the envelope, and nothing checked it: a proposal paying the zero address was signed and published, forfeiting the slot's priority fees and MEV. Reject it in epbsProposalEnvelope, which runs before either signature is requested. The strategies keep their bid check, which becomes load-bearing once proposals built on a staked builder's bid are supported. Also correct the IncludePayload comment. A Gloas block never carries an execution payload, it commits to a bid, so the option selects whether the payload envelope travels back with the block or stays cached on the producing node rather than whether the block includes a payload. --- CHANGELOG.md | 1 + .../standard/gloas_test.go | 30 ++++++++++++++----- .../beaconblockproposer/standard/propose.go | 16 ++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2999cab3..c8f540d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ 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 diff --git a/services/beaconblockproposer/standard/gloas_test.go b/services/beaconblockproposer/standard/gloas_test.go index 31d718c8..7092e430 100644 --- a/services/beaconblockproposer/standard/gloas_test.go +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -23,6 +23,7 @@ import ( mockblockauctioneer "github.com/attestantio/go-block-relay/services/blockauctioneer/mock" consensusapi "github.com/attestantio/go-eth2-client/api" mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/deneb" "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" @@ -61,6 +62,7 @@ func TestProposeGloas(t *testing.T) { foreignBuilderIndex bool envelopeRootMismatch bool envelopePayloadMissing bool + envelopeZeroFeeRecipient bool executionPayloadBidMissing bool envelopeSignerErr error envelopeSubmitterErr error @@ -110,6 +112,12 @@ func TestProposeGloas(t *testing.T) { envelopePayloadMissing: true, err: "failed to propose block: ePBS execution payload envelope has no payload", }, + { + name: "ZeroPayloadFeeRecipient", + executionPayloadIncluded: true, + envelopeZeroFeeRecipient: true, + err: "failed to propose block: ePBS execution payload envelope has 0 fee recipient", + }, { name: "MissingExecutionPayloadBid", executionPayloadIncluded: true, @@ -165,7 +173,7 @@ func TestProposeGloas(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -182,6 +190,9 @@ func TestProposeGloas(t *testing.T) { if test.envelopePayloadMissing { response.Data.GloasContents.ExecutionPayloadEnvelope.Payload = nil } + if test.envelopeZeroFeeRecipient { + response.Data.GloasContents.ExecutionPayloadEnvelope.Payload.FeeRecipient = bellatrix.ExecutionAddress{} + } if test.executionPayloadBidMissing { response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid = nil } @@ -384,11 +395,14 @@ func TestProposeGloasProposalSourceSubmissionFailure(t *testing.T) { require.Nil(t, envelopeSubmitter.opts) } -func setSelfBuildBuilderIndex(t *testing.T, proposal *consensusapi.VersionedEPBSProposal) { +// setSelfBuildProposal marks a mock proposal as self-built and gives its payload a +// fee recipient. The mock leaves the fee recipient zero, which the proposer rejects. +func setSelfBuildProposal(t *testing.T, proposal *consensusapi.VersionedEPBSProposal) { t.Helper() proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = gloas.BuilderIndex(math.MaxUint64) proposal.GloasContents.ExecutionPayloadEnvelope.BuilderIndex = gloas.BuilderIndex(math.MaxUint64) + proposal.GloasContents.ExecutionPayloadEnvelope.Payload.FeeRecipient = bellatrix.ExecutionAddress{0x06} bodyRoot, err := proposal.GloasContents.Block.Body.HashTreeRoot() require.NoError(t, err) convertedBodyRoot := phase0.Root(bodyRoot) @@ -421,7 +435,7 @@ func newGloasProposerForProposalSource( if response.Data.ExecutionPayloadIncluded { response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -504,7 +518,7 @@ func TestProposeGloasSignsRetainedBodyRoot(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) block := response.Data.GloasContents.Block generatedRoot, err := block.Body.HashTreeRoot() @@ -591,7 +605,7 @@ func TestProposeGloasMissingBodyRootFails(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -651,7 +665,7 @@ func TestProposeGloasStartsBothSignaturesBeforePublication(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -740,7 +754,7 @@ func TestProposeGloasCancelsPeerSigningAfterFailure(t *testing.T) { } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot @@ -830,7 +844,7 @@ func TestProposeGloasCancelsBlockedBlockSigningAfterEnvelopeFailure(t *testing.T } response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} - setSelfBuildBuilderIndex(t, response.Data) + setSelfBuildProposal(t, response.Data) blockRoot, err := response.Data.GloasContents.Block.HashTreeRoot() require.NoError(t, err) response.Data.GloasContents.ExecutionPayloadEnvelope.BeaconBlockRoot = blockRoot diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 253ed315..dc09ef65 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -247,8 +247,13 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, s.log.Warn().Msg("Ignoring non-default builder boost factor on Gloas proposal path") } + // A Gloas block never carries an execution payload: it commits to a bid, and the + // payload is revealed separately as an envelope. IncludePayload selects whether + // that envelope travels back with the block or stays cached on the producing node, + // so asking for it is what keeps the reveal publishable through any beacon node + // rather than only the one that built the payload. includePayload := true - // Force local building and include its payload so any beacon node can publish it. + // Force local building. selfBuildBoostFactor := uint64(0) proposalResponse, err := s.proposalProvider.EPBSProposal(ctx, &api.EPBSProposalOpts{ Slot: duty.Slot(), @@ -343,7 +348,7 @@ func (s *Service) proposeEPBSBlock(ctx context.Context, } // epbsProposalEnvelope obtains the execution payload envelope and body root for a proposal, -// confirming that the envelope is for the proposed block. +// confirming that the envelope is for the proposed block and pays a fee recipient. func (*Service) epbsProposalEnvelope(proposal *api.VersionedEPBSProposal) (*gloas.ExecutionPayloadEnvelope, phase0.Root, error) { envelope, err := proposal.ExecutionPayloadEnvelope() if err != nil { @@ -352,6 +357,13 @@ func (*Service) epbsProposalEnvelope(proposal *api.VersionedEPBSProposal) (*gloa if envelope.Payload == nil { return nil, phase0.Root{}, errors.New("ePBS execution payload envelope has no payload") } + // The bid's fee recipient is the one the strategies check, but a self-built bid pays + // nothing: the spec requires bid.value to be zero and records no builder payment for + // it. The payload's own fee recipient collects the slot's priority fees and MEV, so + // it is the one that has to be checked before this envelope is signed. + if envelope.Payload.FeeRecipient.IsZero() { + return nil, phase0.Root{}, errors.New("ePBS execution payload envelope has 0 fee recipient") + } if proposal.GloasContents == nil || proposal.GloasContents.Block == nil || proposal.GloasContents.Block.Body == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid == nil || proposal.GloasContents.Block.Body.SignedExecutionPayloadBid.Message == nil { return nil, phase0.Root{}, errors.New("ePBS proposal has no execution payload bid") }