diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea2a167..c8f540d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +gloas: + - add gloas self-build beacon block proposal path: request an ePBS proposal with its payload, sign the beacon block and matching execution payload envelope, then publish the block followed by its envelope + - refuse a self-build gloas proposal whose execution payload pays no fee recipient, before its envelope is signed + - extend the first and best beaconblockproposal strategies for ePBS proposals, rejecting malformed or payload-excluded responses when payload inclusion was requested + - route the simple beaconblockproposal style through the first strategy; beacon node clients that do not support ePBS proposals now fail at startup + - skip the relay auction under gloas and warn operators that their relay settings are ignored + - add eth2client.custom-spec-support to enable dynamic SSZ encoding and decoding for beacon nodes using a non-mainnet preset + - cache the hard fork schedule in chaintime; known-fork lookups are allocation-free and unknown forks trigger a coalesced refresh + - do not update execution chain head state from gloas blocks, as an execution payload bid carries no execution block number + - report "builder" as a beacon block proposal source method + - update go-eth2-client to a gloas pseudo-version + 1.13.1: - initialise sync committee verification metrics to 0 - use dynssz v1.3.2 and corresponding lib updates 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 diff --git a/clients.go b/clients.go index af812b13..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 @@ -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..6db48091 --- /dev/null +++ b/clients_test.go @@ -0,0 +1,145 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/binary" + nethttp "net/http" + "net/http/httptest" + "testing" + "time" + + bitfield "github.com/OffchainLabs/go-bitfield" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1gloas "github.com/attestantio/go-eth2-client/api/v1/gloas" + mockconsensusclient "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/bellatrix" + "github.com/attestantio/go-eth2-client/spec/gloas" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/attestantio/vouch/services/metrics/null" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestFetchClientCustomSpecSupport(t *testing.T) { + ctx := context.Background() + customSSZ := dynssz.NewDynSsz(map[string]any{ + "SYNC_COMMITTEE_SIZE": uint64(32), + }, dynssz.WithNoFastSsz()) + block := &gloas.BeaconBlock{ + Slot: 1, + Body: &gloas.BeaconBlockBody{ + SyncAggregate: &altair.SyncAggregate{ + SyncCommitteeBits: bitfield.Bitvector512{0, 0, 0, 0}, + }, + }, + } + body, err := customSSZ.MarshalSSZ(block.Body) + require.NoError(t, err) + require.EqualValues(t, 336, binary.LittleEndian.Uint32(body[200:204])) + encoded, err := customSSZ.MarshalSSZ(block) + require.NoError(t, err) + + server := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"1","sync_distance":"0"}}`)) + case "/eth/v1/config/spec": + _, _ = w.Write([]byte(`{"data":{"SYNC_COMMITTEE_SIZE":"32"}}`)) + case "/eth/v4/validator/blocks/1": + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Eth-Consensus-Version", "gloas") + w.Header().Set("Eth-Execution-Payload-Included", "false") + _, _ = w.Write(encoded) + default: + t.Errorf("unexpected request %s", r.URL.Path) + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer server.Close() + + viper.Set("fetch-client-test-sentinel", "must not leak") + viper.Set("timeout", "2s") + viper.Set("eth2client.timeout", "2s") + viper.Set("eth2client.custom-spec-support", true) + t.Cleanup(func() { + viper.Reset() + require.Nil(t, viper.Get("fetch-client-test-sentinel")) + knownClientsMu.Lock() + delete(knownClients, server.URL) + knownClientsMu.Unlock() + }) + + service, err := fetchClient(ctx, null.New(), server.URL) + require.NoError(t, err) + + includePayload := false + response, err := service.(client.EPBSProposalProvider).EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: 1, + IncludePayload: &includePayload, + }) + require.NoError(t, err) + require.Equal(t, spec.DataVersionGloas, response.Data.Version) + require.Equal(t, block.Slot, response.Data.Gloas.Slot) +} + +func TestSimpleProposalProviderRejectsZeroFeeRecipient(t *testing.T) { + ctx := context.Background() + const address = "http://proposal.test" + proposalClient, err := mockconsensusclient.New(ctx) + require.NoError(t, err) + proposalClient.EPBSProposalFunc = func(context.Context, *api.EPBSProposalOpts) (*api.Response[*api.VersionedEPBSProposal], error) { + return &api.Response[*api.VersionedEPBSProposal]{ + Data: &api.VersionedEPBSProposal{ + Version: spec.DataVersionGloas, + ExecutionPayloadIncluded: true, + GloasContents: &apiv1gloas.BlockContents{Block: &gloas.BeaconBlock{Body: &gloas.BeaconBlockBody{ + SignedExecutionPayloadBid: &gloas.SignedExecutionPayloadBid{Message: &gloas.ExecutionPayloadBid{ + FeeRecipient: bellatrix.ExecutionAddress{}, + }}, + }}}, + }, + }, nil + } + viper.Set("strategies.beaconblockproposal.style", "simple") + viper.Set("strategies.beaconblockproposal.beacon-node-addresses", []string{address}) + viper.Set("strategies.beaconblockproposal.first.timeout", 10*time.Millisecond) + knownClientsMu.Lock() + knownClients[address] = proposalClient + knownClientsMu.Unlock() + t.Cleanup(func() { + viper.Reset() + knownClientsMu.Lock() + delete(knownClients, address) + delete(knownClients, "multi:"+address) + knownClientsMu.Unlock() + }) + + provider, err := selectProposalProvider(ctx, null.New(), nil, nil, nil) + require.NoError(t, err) + includePayload := true + response, err := provider.EPBSProposal(ctx, &api.EPBSProposalOpts{ + Slot: phase0.Slot(1), + IncludePayload: &includePayload, + }) + require.Nil(t, response) + require.EqualError(t, err, "failed to obtain ePBS beacon block proposal before timeout") +} 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/go.mod b/go.mod index e85ee9ba..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.0 + github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6 github.com/aws/aws-sdk-go v1.55.6 github.com/google/uuid v1.6.0 github.com/holiman/uint256 v1.3.2 diff --git a/go.sum b/go.sum index 8a92d48e..4d3bdbf9 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,10 @@ github.com/attestantio/go-certmanager v0.2.0 h1:Hzj12L5fofK7b281uohMBN0HQuSx+8Rf github.com/attestantio/go-certmanager v0.2.0/go.mod h1:Dn+C/okccD+2RugizT1ryrjX65cBMZl55fNYtmaVYAg= github.com/attestantio/go-eth2-client v0.29.0 h1:nOVPR6boXuGn5yg94pVOKcaoiO9yyjaYbM1vzwPF4n4= github.com/attestantio/go-eth2-client v0.29.0/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= +github.com/attestantio/go-eth2-client v0.29.1-0.20260811144708-94db679b233c h1:fuiXnbUXUh6BbcV/ZkZHEuN39zLRqyMstKNg0fe6ZvQ= +github.com/attestantio/go-eth2-client v0.29.1-0.20260811144708-94db679b233c/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= +github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6 h1:U59++PPwmKYGKg2Fnmvpf5EQCLW1oi/13GcRK+mU6A4= +github.com/attestantio/go-eth2-client v0.29.1-0.20260813075519-56e9a537aee6/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= github.com/aws/aws-sdk-go v1.44.81/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= diff --git a/main.go b/main.go index 6a24835a..161a7728 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 @@ -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) @@ -670,7 +671,7 @@ func startProviders(ctx context.Context, cache cache.Service, ) ( graffitiprovider.Service, - eth2client.ProposalProvider, + beaconblockproposer.ProposalDataProvider, eth2client.AttestationDataProvider, eth2client.AggregateAttestationProvider, error, @@ -814,8 +815,10 @@ func startSigningServices(ctx context.Context, standardbeaconblockproposer.WithGraffitiProvider(graffitiProvider), standardbeaconblockproposer.WithMonitor(monitor), standardbeaconblockproposer.WithProposalSubmitter(submitterStrategy.(submitter.ProposalSubmitter)), + standardbeaconblockproposer.WithExecutionPayloadEnvelopeSubmitter(submitterStrategy.(submitter.ExecutionPayloadEnvelopeSubmitter)), standardbeaconblockproposer.WithRANDAORevealSigner(signerSvc.(signer.RANDAORevealSigner)), standardbeaconblockproposer.WithBeaconBlockSigner(signerSvc.(signer.BeaconBlockSigner)), + standardbeaconblockproposer.WithExecutionPayloadEnvelopeSigner(signerSvc.(signer.ExecutionPayloadEnvelopeSigner)), standardbeaconblockproposer.WithBlobSidecarSigner(signerSvc.(signer.BlobSidecarSigner)), standardbeaconblockproposer.WithUnblindFromAllRelays(viper.GetBool("beaconblockproposer.unblind-from-all-relays")), standardbeaconblockproposer.WithBuilderBoostFactor(viper.GetUint64("beaconblockproposer.builder-boost-factor")), @@ -1398,19 +1401,23 @@ func selectProposalProvider(ctx context.Context, eth2Client eth2client.Service, chainTime chaintime.Service, cacheSvc cache.Service, -) (eth2client.ProposalProvider, error) { - var proposalProvider eth2client.ProposalProvider +) (beaconblockproposer.ProposalDataProvider, error) { + var proposalProvider beaconblockproposer.ProposalDataProvider var err error switch viper.GetString("strategies.beaconblockproposal.style") { case "best": log.Info().Msg("Starting best beacon block proposal strategy") - proposalProviders := make(map[string]eth2client.ProposalProvider) + proposalProviders := make(map[string]beaconblockproposer.ProposalDataProvider) for _, address := range util.BeaconNodeAddresses("strategies.beaconblockproposal.best") { client, err := fetchClient(ctx, monitor, address) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("failed to fetch client %s for beacon block proposal strategy", address)) } - proposalProviders[address] = client.(eth2client.ProposalProvider) + provider, isProvider := client.(beaconblockproposer.ProposalDataProvider) + if !isProvider { + return nil, errors.New("beacon block proposal client does not support ePBS proposals") + } + proposalProviders[address] = provider } proposalProvider, err = bestbeaconblockproposalstrategy.New(ctx, bestbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)), @@ -1428,13 +1435,17 @@ func selectProposalProvider(ctx context.Context, } case "first": log.Info().Msg("Starting first beacon block proposal strategy") - proposalProviders := make(map[string]eth2client.ProposalProvider) + proposalProviders := make(map[string]beaconblockproposer.ProposalDataProvider) for _, address := range util.BeaconNodeAddresses("strategies.beaconblockproposal.first") { client, err := fetchClient(ctx, monitor, address) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("failed to fetch client %s for beacon block proposal strategy", address)) } - proposalProviders[address] = client.(eth2client.ProposalProvider) + provider, isProvider := client.(beaconblockproposer.ProposalDataProvider) + if !isProvider { + return nil, errors.New("beacon block proposal client does not support ePBS proposals") + } + proposalProviders[address] = provider } proposalProvider, err = firstbeaconblockproposalstrategy.New(ctx, firstbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)), @@ -1451,7 +1462,21 @@ func selectProposalProvider(ctx context.Context, if err != nil { return nil, errors.Wrap(err, "failed to fetch clients for simple beacon block proposal strategy") } - proposalProvider = beaconBlockProposalClient.(eth2client.ProposalProvider) + provider, isProvider := beaconBlockProposalClient.(beaconblockproposer.ProposalDataProvider) + if !isProvider { + return nil, errors.New("beacon block proposal client does not support ePBS proposals") + } + proposalProvider, err = firstbeaconblockproposalstrategy.New(ctx, + firstbeaconblockproposalstrategy.WithClientMonitor(monitor.(metrics.ClientMonitor)), + firstbeaconblockproposalstrategy.WithLogLevel(util.LogLevel("strategies.beaconblockproposal.first")), + firstbeaconblockproposalstrategy.WithProposalProviders(map[string]beaconblockproposer.ProposalDataProvider{ + "simple": provider, + }), + firstbeaconblockproposalstrategy.WithTimeout(util.Timeout("strategies.beaconblockproposal.first")), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to start simple beacon block proposal strategy") + } } return proposalProvider, nil @@ -1612,6 +1637,7 @@ func selectSubmitterStrategy(ctx context.Context, monitor metrics.Service, eth2C immediatesubmitter.WithLogLevel(util.LogLevel("submitter.immediate")), immediatesubmitter.WithClientMonitor(monitor.(metrics.ClientMonitor)), immediatesubmitter.WithProposalSubmitter(eth2Client.(eth2client.ProposalSubmitter)), + immediatesubmitter.WithExecutionPayloadEnvelopeSubmitter(eth2Client.(eth2client.ExecutionPayloadEnvelopeSubmitter)), immediatesubmitter.WithAttestationsSubmitter(eth2Client.(eth2client.AttestationsSubmitter)), immediatesubmitter.WithSyncCommitteeMessagesSubmitter(eth2Client.(eth2client.SyncCommitteeMessagesSubmitter)), immediatesubmitter.WithSyncCommitteeContributionsSubmitter(eth2Client.(eth2client.SyncCommitteeContributionsSubmitter)), @@ -1669,6 +1695,12 @@ func startMultinodeSubmitter(ctx context.Context, if err != nil { return nil, err } + executionPayloadEnvelopeSubmitters, err := genericAddressToClientMapper[eth2client.ExecutionPayloadEnvelopeSubmitter](ctx, monitor, + "submitter.proposal.multinode", + "execution payload envelope submitter strategy") + if err != nil { + return nil, err + } beaconCommitteeSubscriptionsSubmitters, err := genericAddressToClientMapper[eth2client.BeaconCommitteeSubscriptionsSubmitter](ctx, monitor, "submitter.beaconcommitteesubscription.multinode", @@ -1711,6 +1743,7 @@ func startMultinodeSubmitter(ctx context.Context, multinodesubmitter.WithLogLevel(util.LogLevel("submitter.multinode")), multinodesubmitter.WithTimeout(util.Timeout("submitter.multinode")), multinodesubmitter.WithProposalSubmitters(proposalSubmitters), + multinodesubmitter.WithExecutionPayloadEnvelopeSubmitters(executionPayloadEnvelopeSubmitters), multinodesubmitter.WithAttestationsSubmitters(attestationsSubmitters), multinodesubmitter.WithSyncCommitteeMessagesSubmitters(syncCommitteeMessagesSubmitters), multinodesubmitter.WithSyncCommitteeContributionsSubmitters(syncCommitteeContributionsSubmitters), diff --git a/mock/eth2client.go b/mock/eth2client.go index 958c8366..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 @@ -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..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 @@ -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..7092e430 --- /dev/null +++ b/services/beaconblockproposer/standard/gloas_test.go @@ -0,0 +1,1154 @@ +// 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" + "math" + "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/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" + 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" + "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" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" +) + +// skipcq: GO-R1005 +func TestProposeGloas(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + executionPayloadIncluded bool + blockAuctioneer bool + builderBoostFactor uint64 + proposerIndexMismatch bool + builderIndexMismatch bool + foreignBuilderIndex bool + envelopeRootMismatch bool + envelopePayloadMissing bool + envelopeZeroFeeRecipient bool + executionPayloadBidMissing bool + envelopeSignerErr error + envelopeSubmitterErr error + forkEpochAtConstruction phase0.Epoch + forkEpochAtUse phase0.Epoch + updateForkEpochAtUse bool + err string + }{ + { + name: "PayloadIncluded", + executionPayloadIncluded: true, + builderBoostFactor: 100, + }, + { + name: "ForkEpochAvailableAfterConstruction", + executionPayloadIncluded: true, + forkEpochAtConstruction: phase0.Epoch(^uint64(0)), + forkEpochAtUse: 0, + updateForkEpochAtUse: true, + }, + { + name: "ConfiguredAuctioneer", + executionPayloadIncluded: true, + blockAuctioneer: true, + }, + { + name: "MismatchedProposerIndex", + executionPayloadIncluded: true, + 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: "ForeignBuilderIndex", + executionPayloadIncluded: true, + foreignBuilderIndex: true, + err: "failed to propose block: ePBS execution payload bid is not self-built", + }, + { + name: "MissingEnvelopePayload", + executionPayloadIncluded: true, + 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, + executionPayloadBidMissing: true, + err: "failed to propose block: ePBS proposal has no execution payload bid", + }, + { + 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", + }, + { + name: "EnvelopeSigningFailure", + executionPayloadIncluded: true, + 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 { + 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) { + 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 { + if test.proposerIndexMismatch { + response.Data.GloasContents.Block.ProposerIndex++ + } + response.Data.GloasContents.KZGProofs = []deneb.KZGProof{{0x04}} + response.Data.GloasContents.Blobs = []deneb.Blob{{0x05}} + setSelfBuildProposal(t, response.Data) + 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 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 + } + if test.envelopeZeroFeeRecipient { + response.Data.GloasContents.ExecutionPayloadEnvelope.Payload.FeeRecipient = bellatrix.ExecutionAddress{} + } + if test.executionPayloadBidMissing { + response.Data.GloasContents.Block.Body.SignedExecutionPayloadBid = nil + } + } + 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, err: test.envelopeSignerErr} + envelopeSubmitter := &capturingExecutionPayloadEnvelopeSubmitter{err: test.envelopeSubmitterErr} + + chainTime := &forkChainTime{gloasForkEpoch: test.forkEpochAtConstruction} + var monitor metrics.Service = nullmetrics.New() + + params := []standard.Parameter{ + standard.WithLogLevel(zerolog.TraceLevel), + standard.WithMonitor(monitor), + standard.WithProposalDataProvider(proposalClient), + standard.WithChainTime(chainTime), + standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), + standard.WithProposalSubmitter(proposalSubmitter), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(envelopeSigner), + standard.WithExecutionPayloadEnvelopeSubmitter(envelopeSubmitter), + standard.WithBlobSidecarSigner(signer), + 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) + if test.updateForkEpochAtUse { + chainTime.gloasForkEpoch = test.forkEpochAtUse + } + + duty := beaconblockproposer.NewDuty(1, 0) + duty.SetAccount(&testAccount{}) + 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) + require.NotNil(t, epbsOpts.BuilderBoostFactor) + require.Equal(t, uint64(0), *epbsOpts.BuilderBoostFactor) + if test.err != "" { + require.EqualError(t, err, test.err) + 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.foreignBuilderIndex || test.envelopePayloadMissing || test.executionPayloadBidMissing { + require.Zero(t, blockSigner.calls) + 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) + require.Same(t, responseProposal.GloasContents.ExecutionPayloadEnvelope, envelopeSigner.envelope) + 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) + 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) + } + }) + } +} + +func TestProposeGloasProposalSource(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + executionPayloadIncluded bool + envelopeSubmissionErr error + 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", + }, + { + 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 { + 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(ctx, t, test.executionPayloadIncluded, monitor) + envelopeSubmitter.err = test.envelopeSubmissionErr + + err = service.Propose(ctx, duty) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + } + require.Equal(t, proposalSourceCountBefore+test.expectedCountDelta, beaconBlockProposalSourceCount(t, test.source)) + if !test.executionPayloadIncluded { + require.Zero(t, blockSigner.calls) + require.Zero(t, envelopeSigner.calls) + require.Nil(t, envelopeSubmitter.opts) + } + }) + } +} + +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) +} + +// 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) + proposal.BeaconBlockBodyRoot = &convertedBodyRoot +} + +func newGloasProposerForProposalSource( + ctx context.Context, + t *testing.T, + executionPayloadIncluded bool, + monitor metrics.Service, +) (*standard.Service, + *beaconblockproposer.Duty, + *capturingBeaconBlockSigner, + *capturingExecutionPayloadEnvelopeSigner, + *capturingExecutionPayloadEnvelopeSubmitter, + *capturingProposalSubmitter, +) { + 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}} + setSelfBuildProposal(t, response.Data) + 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, proposalSubmitter +} + +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 +// 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}} + setSelfBuildProposal(t, response.Data) + + 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}} + setSelfBuildProposal(t, response.Data) + 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) + 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}} + setSelfBuildProposal(t, response.Data) + 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}} + setSelfBuildProposal(t, response.Data) + 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}} + setSelfBuildProposal(t, response.Data) + 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() + + 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.WithExecutionPayloadEnvelopeSubmitter(responseClient), + standard.WithRANDAORevealSigner(signer), + standard.WithBeaconBlockSigner(blockSigner), + standard.WithExecutionPayloadEnvelopeSigner(signer), + 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 + calls int + err error +} + +func (s *capturingProposalSubmitter) SubmitProposal(_ context.Context, proposal *consensusapi.VersionedSignedProposal) error { + s.calls++ + s.proposal = proposal + + return s.err +} + +var _ submitter.ProposalSubmitter = (*capturingProposalSubmitter)(nil) + +type capturingBeaconBlockSigner struct { + signature phase0.BLSSignature + bodyRoot phase0.Root + calls int + err error + started chan<- struct{} + release <-chan struct{} +} + +type capturingExecutionPayloadEnvelopeSigner struct { + signature phase0.BLSSignature + envelope *gloas.ExecutionPayloadEnvelope + calls int + err error + started chan<- struct{} + release <-chan struct{} +} + +func (s *capturingExecutionPayloadEnvelopeSigner) SignExecutionPayloadEnvelope( + _ context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + envelope *gloas.ExecutionPayloadEnvelope, +) (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 + } + return s.signature, nil +} + +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 + calls int + err error +} + +func (s *capturingExecutionPayloadEnvelopeSubmitter) SubmitExecutionPayloadEnvelope( + _ context.Context, + opts *consensusapi.SubmitExecutionPayloadEnvelopeOpts, +) error { + s.calls++ + s.opts = opts + + return s.err +} + +var _ submitter.ExecutionPayloadEnvelopeSubmitter = (*capturingExecutionPayloadEnvelopeSubmitter)(nil) + +func (s *capturingBeaconBlockSigner) SignBeaconBlockProposal( + _ context.Context, + _ e2wtypes.Account, + _ phase0.Slot, + _ phase0.ValidatorIndex, + _ phase0.Root, + _ phase0.Root, + bodyRoot phase0.Root, +) (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 +} + +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/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/parameters.go b/services/beaconblockproposer/standard/parameters.go index 633999ce..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 @@ -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) { @@ -193,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 ac2a8263..dc09ef65 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 @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strings" "time" @@ -37,12 +38,14 @@ 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" "github.com/pkg/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" ) @@ -140,6 +143,10 @@ func (s *Service) proposeBlock(ctx context.Context, duty *beaconblockproposer.Duty, graffiti [32]byte, ) error { + if s.chainTime.SlotToEpoch(duty.Slot()) >= s.chainTime.HardForkEpoch(ctx, "GLOAS_FORK_EPOCH") { + return s.proposeEPBSBlock(ctx, duty, graffiti) + } + var auctionResults *blockauctioneer.Results var err error if s.blockAuctioneer != nil { @@ -177,39 +184,215 @@ 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 + providers, err := s.unblindingProviders(auctionResults) + if err != nil { + return err } - - 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 err := s.unblindProposal(ctx, signedProposal, providers); err != nil { + return errors.Wrap(err, "failed to unblind block") } - if len(providers) == 0 { - return errors.New("no relays to unblind the block") + } + + if err := s.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { + return errors.Wrap(err, "failed to submit proposal") + } + + 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) + } - 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") + 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. +// skipcq: GO-R1005 +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") + } + 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") + } + + // 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. + 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 + if !proposal.ExecutionPayloadIncluded { + return errors.New("ePBS proposal excludes requested execution payload") + } + + if err := s.confirmEPBSProposalData(ctx, proposal, duty); err != nil { + return err + } + + envelope, bodyRoot, err := s.epbsProposalEnvelope(proposal) + if err != nil { + return err + } + + 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") } + + return nil + }) + if err := signingGroup.Wait(); err != nil { + return err + } + 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.proposalSubmitter.SubmitProposal(ctx, signedProposal); err != nil { return errors.Wrap(err, "failed to submit proposal") } + envelopeSubmissionOpts := &api.SubmitExecutionPayloadEnvelopeOpts{ + SignedExecutionPayloadEnvelope: &spec.VersionedSignedExecutionPayloadEnvelope{ + Version: spec.DataVersionGloas, + Gloas: &gloas.SignedExecutionPayloadEnvelope{ + Message: envelope, + Signature: signature, + }, + }, + KZGProofs: kzgProofs, + Blobs: blobs, + } + 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") + } + monitorBeaconBlockProposalSource("local") + return nil } +// epbsProposalEnvelope obtains the execution payload envelope and body root for a proposal, +// 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 { + 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") + } + // 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") + } + 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") + } + 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, @@ -230,6 +413,28 @@ 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") + } + 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 +} + // skipcq: GO-R1005 // Complexity is due to handling all Ethereum protocol versions. // Each version requires specific signing logic for blinded/unblinded proposals. @@ -363,6 +568,55 @@ func (s *Service) signProposalData(ctx context.Context, return signedProposal, nil } +func (s *Service) signEPBSProposalData(ctx context.Context, + proposal *api.VersionedEPBSProposal, + duty *beaconblockproposer.Duty, + bodyRoot phase0.Root, +) ( + *api.VersionedSignedProposal, + error, +) { + if proposal.Version != spec.DataVersionGloas { + return nil, errors.New("unhandled ePBS proposal version") + } + + 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/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.go b/services/beaconblockproposer/standard/service.go index 959fbd6a..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 @@ -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,21 @@ 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 } // New creates a new beacon block proposer. @@ -71,19 +72,21 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { } 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, } return s, nil 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/cache/standard/events.go b/services/cache/standard/events.go index de56767e..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 { 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) +} diff --git a/services/chaintime/standard/service.go b/services/chaintime/standard/service.go index 99cc4ecc..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 @@ -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,149 @@ 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() + // 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 + } + 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. +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) + } + + return res +} + +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) { + 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() + } + } + 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 { - return 0, errors.Wrap(err, "failed to obtain spec") + err = errors.Wrap(err, "failed to obtain spec") + } else { + current := s.forkSchedule.Load() + incoming := forkScheduleFromSpec(specResponse.Data) + next := mergeForkSchedule(current, incoming, s.CurrentEpoch()) + if next != nil { + s.forkSchedule.Store(next) + } } - spec := specResponse.Data - tmp, exists := spec[hardForkName] - if !exists { - return 0, fmt.Errorf("%s version not known by chain", hardForkName) + 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), + } + } + } + + for name, epoch := range incoming.epochs { + if existingEpoch, exists := current.epochs[name]; exists { + if existingEpoch == epoch { + continue + } + // Once either schedule activates a fork, retain the last-known-good boundary. + if existingEpoch <= currentEpoch || epoch <= currentEpoch { + continue + } + } + cloneCurrent() + next.epochs[name] = epoch + delete(next.malformed, name) } - epoch, isEpoch := tmp.(uint64) - if !isEpoch { - return 0, fmt.Errorf("%s is not a uint64", hardForkName) + + 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 phase0.Epoch(epoch), nil + return next } 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) +} diff --git a/services/signer/mock/service.go b/services/signer/mock/service.go index 8f9f8625..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 @@ -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..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 @@ -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..b0385098 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 @@ -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. @@ -101,34 +102,23 @@ 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 - } + 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 blobSidecarDomainType *phase0.DomainType - if tmp, err := domainType(spec, "DOMAIN_BLOB_SIDECAR"); err == nil { - blobSidecarDomainType = &tmp + 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{ monitor: parameters.monitor, clientMonitor: parameters.clientMonitor, + domainProvider: parameters.domainProvider, slotsPerEpoch: phase0.Slot(slotsPerEpoch), beaconAttesterDomainType: beaconAttesterDomainType, beaconProposerDomainType: beaconProposerDomainType, @@ -140,7 +130,7 @@ func New(ctx context.Context, params ...Parameter) (*Service, error) { contributionAndProofDomainType: contributionAndProofDomainType, applicationBuilderDomainType: applicationBuilderDomainType, blobSidecarDomainType: blobSidecarDomainType, - domainProvider: parameters.domainProvider, + beaconBuilderDomainType: beaconBuilderDomainType, } return s, nil @@ -157,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/services/signer/standard/signexecutionpayloadenvelope.go b/services/signer/standard/signexecutionpayloadenvelope.go new file mode 100644 index 00000000..46f98699 --- /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("DOMAIN_BEACON_BUILDER unavailable in beacon node spec; cannot sign execution payload envelope") + } + + 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..7f87cee6 --- /dev/null +++ b/services/signer/standard/signexecutionpayloadenvelope_internal_test.go @@ -0,0 +1,137 @@ +// 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/attestantio/vouch/testing/logger" + "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, "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) { + 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..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 @@ -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) { @@ -134,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.go b/services/submitter/immediate/service.go index 47f2ce5e..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 @@ -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/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/immediate/submitexecutionpayloadenvelope.go b/services/submitter/immediate/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..950924b6 --- /dev/null +++ b/services/submitter/immediate/submitexecutionpayloadenvelope.go @@ -0,0 +1,50 @@ +// 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" + "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") + } + 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..09644dea --- /dev/null +++ b/services/submitter/immediate/submitexecutionpayloadenvelope_test.go @@ -0,0 +1,84 @@ +// 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" + "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), + 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) + + 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(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { + s.ctx = ctx + 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..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 @@ -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) { @@ -159,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.go b/services/submitter/multinode/service.go index f546bb87..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 @@ -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/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/submitexecutionpayloadenvelope.go b/services/submitter/multinode/submitexecutionpayloadenvelope.go new file mode 100644 index 00000000..a44d92a4 --- /dev/null +++ b/services/submitter/multinode/submitexecutionpayloadenvelope.go @@ -0,0 +1,116 @@ +// 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" + "sync" + "sync/atomic" + "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) + + sem := semaphore.NewWeighted(s.processConcurrency) + submissionCompleted := make(chan struct{}, 1) + submissionSucceeded := &atomic.Bool{} + var wg sync.WaitGroup + for name, submitter := range s.executionPayloadEnvelopeSubmitters { + 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: + 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, +) { + 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 + } + + submissionSucceeded.Store(true) + 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..d93af2be --- /dev/null +++ b/services/submitter/multinode/submitexecutionpayloadenvelope_test.go @@ -0,0 +1,321 @@ +// 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) +} + +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 +} + +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 +} + +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) 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/services/submitter/service.go b/services/submitter/service.go index 1d363ed6..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 @@ -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..7530b2fe 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 @@ -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,254 @@ 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") + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + 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") + bestProposal, bestProvider = considerEPBSProposal(opts, response, bestProposal, bestProvider, log) + 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 +} + +// 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 +} + +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, +) { + 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 { + 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] + } + var graffiti [32]byte + copy(graffiti[:], providerGraffiti) + opts.Graffiti = graffiti + } + } + + 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 + } + + if proposalResponse == nil || proposalResponse.Data == nil { + errCh <- &beaconBlockError{ + provider: name, + err: errors.New("beacon node returned no ePBS proposal"), + } + + return + } + + if err := validateEPBSProposal(proposalResponse.Data); err != nil { + errCh <- &beaconBlockError{ + provider: name, + err: err, + } + + return + } + + respCh <- &beaconBlockEPBSResponse{ + provider: name, + proposal: proposalResponse.Data, + } +} + +// 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, @@ -70,7 +319,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) @@ -82,16 +332,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 2fb4f273..f51a7f11 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 @@ -18,13 +18,14 @@ 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" + 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" @@ -63,7 +64,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 +80,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 +97,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 +114,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 +132,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 +149,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()), }), @@ -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 new file mode 100644 index 00000000..a2017963 --- /dev/null +++ b/strategies/beaconblockproposal/best/epbsproposal_test.go @@ -0,0 +1,774 @@ +// 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" + 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" + "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" + "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), + standardchaintime.WithGenesisProvider(mock.NewGenesisProvider(time.Now())), + standardchaintime.WithSpecProvider(specProvider), + ) + 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": &testEPBSProposalProvider{proposal: testGloasProposal(1, bellatrix.ExecutionAddress{0x01})}, + "two": &testEPBSProposalProvider{proposal: testGloasProposal(1, bellatrix.ExecutionAddress{0x02})}, + }), + 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) + + 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) { + 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 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 TestEPBSProposalDoesNotWeightExecutionPayloadGas(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{}) + 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, consensusCandidate, response.Data) +} + +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() + 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, + 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() + 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 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) + 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 + delay time.Duration + waitForCancellation bool +} + +type clientGraffitiEPBSProposalProvider struct { + client string + nodeClientErr error + graffiti chan [32]byte +} + +func (p *clientGraffitiEPBSProposalProvider) Proposal(ctx context.Context, + opts *api.ProposalOpts, +) (*api.Response[*api.VersionedProposal], error) { + p.graffiti <- opts.Graffiti + + return mock.NewProposalProvider().Proposal(ctx, opts) +} + +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) { + if p.nodeClientErr != nil { + return nil, p.nodeClientErr + } + 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 (*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.delay != 0 { + time.Sleep(p.delay) + } + 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..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 @@ -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..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 @@ -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..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 @@ -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..2eee0d63 --- /dev/null +++ b/strategies/beaconblockproposal/first/epbsproposal_test.go @@ -0,0 +1,278 @@ +// 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 +// +// 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" + 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" + "github.com/attestantio/vouch/strategies/beaconblockproposal/first" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestEPBSProposal(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{ + "one": &epbsProposalProvider{proposal: gloasEPBSProposal(bellatrix.ExecutionAddress{0x01})}, + }), + 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) +} + +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 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 + 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) +} + +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{} + nilResponse bool +} + +func (*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 + } + if p.nilResponse { + return nil, nil + } + + 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..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 @@ -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..d107ade7 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,8 @@ 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" "github.com/pkg/errors" @@ -33,10 +35,122 @@ 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 s.fetchEPBSProposal(ctx, name, provider, opts, proposalCh) + } + + 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 !s.acceptableEPBSProposal(proposal, opts.IncludePayload) { + continue + } + cancel() + + return &api.Response[*api.VersionedEPBSProposal]{ + Data: proposal, + Metadata: make(map[string]any), + }, nil + } + } +} + +// 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...)