diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0431f3..a90c5201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ dev: - ensure relay configuration inherits all configuration values as expected - create strategies for builder bid + - fetch blinded and unblinded proposals in parallel to speed up block production 1.7.6: - add User-Agent header to HTTP requests diff --git a/services/beaconblockproposer/standard/propose.go b/services/beaconblockproposer/standard/propose.go index 8489b724..aed0f0b8 100644 --- a/services/beaconblockproposer/standard/propose.go +++ b/services/beaconblockproposer/standard/propose.go @@ -1,4 +1,4 @@ -// Copyright © 2020 - 2022 Attestant Limited. +// Copyright © 2020 - 2023 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" + "sync" "time" "github.com/attestantio/go-block-relay/services/blockauctioneer" @@ -67,37 +68,71 @@ func (s *Service) Propose(ctx context.Context, data interface{}) { monitorBeaconBlockProposalCompleted(started, 0, s.chainTime.StartOfSlot(0), "failed") return } - if duty == nil { - log.Error().Msg("Passed nil data structure") - monitorBeaconBlockProposalCompleted(started, 0, s.chainTime.StartOfSlot(0), "failed") + slot, err := validateDuty(duty) + if err != nil { + log.Error().Err(err).Msg("Invalid duty") + monitorBeaconBlockProposalCompleted(started, slot, s.chainTime.StartOfSlot(slot), "failed") return } - span.SetAttributes(attribute.Int64("slot", int64(duty.Slot()))) - log := log.With().Uint64("proposing_slot", uint64(duty.Slot())).Uint64("validator_index", uint64(duty.ValidatorIndex())).Logger() + span.SetAttributes(attribute.Int64("slot", int64(slot))) + log := log.With().Uint64("proposing_slot", uint64(slot)).Uint64("validator_index", uint64(duty.ValidatorIndex())).Logger() log.Trace().Msg("Proposing") - var zeroSig phase0.BLSSignature - if duty.RANDAOReveal() == zeroSig { - log.Error().Msg("Missing RANDAO reveal") - monitorBeaconBlockProposalCompleted(started, duty.Slot(), s.chainTime.StartOfSlot(duty.Slot()), "failed") + graffiti, err := s.obtainGraffiti(ctx, slot, duty.ValidatorIndex()) + if err != nil { + log.Warn().Err(err).Msg("Failed to obtain graffiti") + graffiti = nil + } + + log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained graffiti") + span.AddEvent("Ready to propose") + + if err := s.proposeBlock(ctx, duty, graffiti); err != nil { + log.Error().Err(err).Msg("Failed to propose block") + monitorBeaconBlockProposalCompleted(started, slot, s.chainTime.StartOfSlot(slot), "failed") return } + log.Trace().Dur("elapsed", time.Since(started)).Msg("Submitted proposal") + monitorBeaconBlockProposalCompleted(started, slot, s.chainTime.StartOfSlot(slot), "succeeded") +} + +// validateDuty validates that the information supplied to us in a duty is suitable for proposing. +func validateDuty(duty *beaconblockproposer.Duty) (phase0.Slot, error) { + if duty == nil { + return 0, errors.New("no duty supplied") + } + + zeroSig := phase0.BLSSignature{} + randaoReveal := duty.RANDAOReveal() + if bytes.Equal(randaoReveal[:], zeroSig[:]) { + return duty.Slot(), errors.New("duty missing RANDAO reveal") + } + if duty.Account() == nil { - log.Error().Msg("Missing account") - monitorBeaconBlockProposalCompleted(started, duty.Slot(), s.chainTime.StartOfSlot(duty.Slot()), "failed") - return + return duty.Slot(), errors.New("duty missing account") } - var graffiti []byte - var err error - if s.graffitiProvider != nil { - graffiti, err = s.graffitiProvider.Graffiti(ctx, duty.Slot(), duty.ValidatorIndex()) - if err != nil { - log.Warn().Err(err).Msg("Failed to obtain graffiti") - graffiti = nil - } + return duty.Slot(), nil +} + +// obtainGraffiti obtains the graffiti for the proposal. +func (s *Service) obtainGraffiti(ctx context.Context, + slot phase0.Slot, + validatorIndex phase0.ValidatorIndex, +) ( + []byte, + error, +) { + if s.graffitiProvider == nil { + return []byte{}, nil } + + graffiti, err := s.graffitiProvider.Graffiti(ctx, slot, validatorIndex) + if err != nil { + return []byte{}, errors.Wrap(err, "graffiti provider failed") + } + if bytes.Contains(graffiti, []byte("{{CLIENT}}")) { if nodeClientProvider, isProvider := s.proposalProvider.(consensusclient.NodeClientProvider); isProvider { nodeClient, err := nodeClientProvider.NodeClient(ctx) @@ -111,17 +146,8 @@ func (s *Service) Propose(ctx context.Context, data interface{}) { if len(graffiti) > 32 { graffiti = graffiti[0:32] } - span.AddEvent("Ready to propose") - log.Trace().Dur("elapsed", time.Since(started)).Msg("Obtained graffiti") - if err := s.proposeBlock(ctx, duty, graffiti); err != nil { - log.Error().Err(err).Msg("Failed to propose block") - monitorBeaconBlockProposalCompleted(started, duty.Slot(), s.chainTime.StartOfSlot(duty.Slot()), "failed") - return - } - - log.Trace().Dur("elapsed", time.Since(started)).Msg("Submitted proposal") - monitorBeaconBlockProposalCompleted(started, duty.Slot(), s.chainTime.StartOfSlot(duty.Slot()), "succeeded") + return graffiti, nil } // proposeBlock proposes a beacon block. @@ -129,6 +155,22 @@ func (s *Service) proposeBlock(ctx context.Context, duty *beaconblockproposer.Duty, graffiti []byte, ) error { + // Pre-fetch an unblinded block in parallel with the auction process. + // This ensures that we are ready to propose as quickly as possible if the auction is unsuccessful. + var wg sync.WaitGroup + var proposal *spec.VersionedBeaconBlock + wg.Add(1) + go func(ctx context.Context, duty *beaconblockproposer.Duty, graffiti []byte) { + var err error + proposal, err = s.proposalProvider.BeaconBlockProposal(ctx, duty.Slot(), duty.RANDAOReveal(), graffiti) + if err != nil { + log.Warn().Err(err).Msg("Failed to pre-obtain proposal data") + return + } + log.Trace().Msg("Pre-obtained proposal") + wg.Done() + }(ctx, duty, graffiti) + if s.blockAuctioneer != nil { // There is a block auctioneer specified, try to propose the block with auction. result := s.proposeBlockWithAuction(ctx, duty, graffiti) @@ -145,7 +187,9 @@ func (s *Service) proposeBlock(ctx context.Context, } } - err := s.proposeBlockWithoutAuction(ctx, duty, graffiti) + wg.Wait() + + err := s.proposeBlockWithoutAuction(ctx, proposal, duty, graffiti) if err != nil { return err } @@ -172,6 +216,7 @@ func (s *Service) proposeBlockWithAuction(ctx context.Context, if auctionResults.Bid == nil { return auctionResultNoBids } + monitorBestBidRelayCount(len(auctionResults.Providers)) proposal, err := s.obtainBlindedProposal(ctx, duty, graffiti, auctionResults) if err != nil { @@ -193,7 +238,6 @@ func (s *Service) proposeBlockWithAuction(ctx context.Context, log.Debug().Msg("No relays can unblind the block") return auctionResultFailedCanTryWithout } - monitorBestBidRelayCount(len(providers)) log.Trace().Int("providers", len(providers)).Msg("Obtained relays that can unblind the proposal") signedBlindedBlock, err := s.signBlindedProposal(ctx, duty, proposal) @@ -218,26 +262,29 @@ func (s *Service) proposeBlockWithAuction(ctx context.Context, } func (s *Service) proposeBlockWithoutAuction(ctx context.Context, + proposal *spec.VersionedBeaconBlock, duty *beaconblockproposer.Duty, graffiti []byte, ) error { ctx, span := otel.Tracer("attestantio.vouch.services.beaconblockproposer.standard").Start(ctx, "proposeBlockWithoutAuction") defer span.End() - proposal, err := s.proposalProvider.BeaconBlockProposal(ctx, duty.Slot(), duty.RANDAOReveal(), graffiti) - if err != nil { - return errors.Wrap(err, "failed to obtain proposal data") - } + var err error if proposal == nil { - return errors.New("obtained nil beacon block proposal") + proposal, err = s.proposalProvider.BeaconBlockProposal(ctx, duty.Slot(), duty.RANDAOReveal(), graffiti) + if err != nil { + return errors.Wrap(err, "failed to obtain proposal data") + } + if proposal == nil { + return errors.New("obtained nil beacon block proposal") + } + log.Trace().Msg("Obtained proposal") } - log.Trace().Msg("Obtained proposal") proposalSlot, err := proposal.Slot() if err != nil { return errors.Wrap(err, "failed to obtain proposal slot") } - if proposalSlot != duty.Slot() { return errors.New("proposal data for incorrect slot") } @@ -259,7 +306,7 @@ func (s *Service) proposeBlockWithoutAuction(ctx context.Context, sig, err := s.beaconBlockSigner.SignBeaconBlockProposal(ctx, duty.Account(), - proposalSlot, + duty.Slot(), duty.ValidatorIndex(), parentRoot, stateRoot, @@ -269,6 +316,25 @@ func (s *Service) proposeBlockWithoutAuction(ctx context.Context, } log.Trace().Msg("Signed proposal") + signedBlock, err := composeVersionedSignedBeaconBlock(proposal, sig) + if err != nil { + return err + } + + if err := s.beaconBlockSubmitter.SubmitBeaconBlock(ctx, signedBlock); err != nil { + return errors.Wrap(err, "failed to submit beacon block proposal") + } + + return nil +} + +func composeVersionedSignedBeaconBlock( + proposal *spec.VersionedBeaconBlock, + sig phase0.BLSSignature, +) ( + *spec.VersionedSignedBeaconBlock, + error, +) { signedBlock := &spec.VersionedSignedBeaconBlock{ Version: proposal.Version, } @@ -294,15 +360,10 @@ func (s *Service) proposeBlockWithoutAuction(ctx context.Context, Signature: sig, } default: - return errors.New("unknown proposal version") + return nil, errors.New("unknown proposal version") } - // Submit the block. - if err := s.beaconBlockSubmitter.SubmitBeaconBlock(ctx, signedBlock); err != nil { - return errors.Wrap(err, "failed to submit beacon block proposal") - } - - return nil + return signedBlock, nil } func (s *Service) auctionBlock(ctx context.Context, diff --git a/services/beaconblockproposer/standard/propose_internal_test.go b/services/beaconblockproposer/standard/propose_internal_test.go new file mode 100644 index 00000000..e9e26337 --- /dev/null +++ b/services/beaconblockproposer/standard/propose_internal_test.go @@ -0,0 +1,98 @@ +// Copyright © 2023 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/spec/phase0" + "github.com/attestantio/vouch/services/beaconblockproposer" + "github.com/stretchr/testify/require" + e2types "github.com/wealdtech/go-eth2-types/v2" + keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" + hd "github.com/wealdtech/go-eth2-wallet-hd/v2" + scratch "github.com/wealdtech/go-eth2-wallet-store-scratch" + e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2" +) + +func duty(slot phase0.Slot, validatorIndex phase0.ValidatorIndex, randaoReveal phase0.BLSSignature, account e2wtypes.Account) *beaconblockproposer.Duty { + duty := beaconblockproposer.NewDuty(slot, validatorIndex) + duty.SetRandaoReveal(randaoReveal) + duty.SetAccount(account) + return duty +} + +func TestValidateDuty(t *testing.T) { + ctx := context.Background() + + // Create an account. + require.NoError(t, e2types.InitBLS()) + store := scratch.New() + encryptor := keystorev4.New() + wallet, err := hd.CreateWallet(ctx, "test wallet", []byte("pass"), store, encryptor, make([]byte, 64)) + require.NoError(t, err) + require.Nil(t, wallet.(e2wtypes.WalletLocker).Unlock(ctx, []byte("pass"))) + account, err := wallet.(e2wtypes.WalletAccountCreator).CreateAccount(context.Background(), "test account", []byte("pass")) + require.NoError(t, err) + require.NoError(t, account.(e2wtypes.AccountLocker).Unlock(ctx, []byte("pass"))) + + sig, err := account.(e2wtypes.AccountSigner).Sign(ctx, []byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x00, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + }) + require.NoError(t, err) + randaoReveal := phase0.BLSSignature(sig.Marshal()) + + tests := []struct { + name string + duty *beaconblockproposer.Duty + slot phase0.Slot + err string + }{ + { + name: "Nil", + err: "no duty supplied", + }, + { + name: "NoRandaoReveal", + duty: duty(1, 2, phase0.BLSSignature{}, account), + err: "duty missing RANDAO reveal", + }, + { + name: "NoAccount", + duty: duty(1, 2, randaoReveal, nil), + err: "duty missing account", + }, + { + name: "Good", + duty: duty(1, 2, randaoReveal, account), + slot: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slot, err := validateDuty(test.duty) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + require.Equal(t, test.slot, slot) + } + }) + } +} diff --git a/services/beaconblockproposer/standard/propose_test.go b/services/beaconblockproposer/standard/propose_test.go index 568f7f38..bfc820c1 100644 --- a/services/beaconblockproposer/standard/propose_test.go +++ b/services/beaconblockproposer/standard/propose_test.go @@ -1,4 +1,4 @@ -// Copyright © 2021, 2022 Attestant Limited. +// Copyright © 2021 - 2023 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 @@ -89,33 +89,44 @@ func TestPropose(t *testing.T) { tests := []struct { name string data *beaconblockproposer.Duty - errs []string + errs []map[string]any }{ { name: "Nil", - errs: []string{ - "Passed nil data structure", + errs: []map[string]any{ + { + "message": "Invalid duty", + "error": "no duty supplied", + }, }, }, { name: "Empty", data: duty(phase0.BLSSignature{}, nil), - errs: []string{ - "Missing RANDAO reveal", + errs: []map[string]any{ + { + "message": "Invalid duty", + "error": "duty missing RANDAO reveal", + }, }, }, { name: "AccountMissing", data: duty(phase0.BLSSignature{0x01}, nil), - errs: []string{ - "Missing account", + errs: []map[string]any{ + { + "message": "Invalid duty", + "error": "duty missing account", + }, }, }, { name: "Good", data: duty(phase0.BLSSignature{0x01}, account), - errs: []string{ - "Submitted proposal", + errs: []map[string]any{ + { + "message": "Submitted proposal", + }, }, }, } @@ -140,7 +151,7 @@ func TestPropose(t *testing.T) { s.Propose(ctx, test.data) for _, err := range test.errs { - capture.AssertHasEntry(t, err) + require.True(t, capture.HasLog(err)) } }) } diff --git a/services/beaconblockproposer/standard/service_test.go b/services/beaconblockproposer/standard/service_test.go index 76ea879c..7d5b4f41 100644 --- a/services/beaconblockproposer/standard/service_test.go +++ b/services/beaconblockproposer/standard/service_test.go @@ -23,7 +23,6 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/attestantio/vouch/mock" 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" @@ -31,7 +30,6 @@ import ( staticgraffitiprovider "github.com/attestantio/vouch/services/graffitiprovider/static" nullmetrics "github.com/attestantio/vouch/services/metrics/null" mocksigner "github.com/attestantio/vouch/services/signer/mock" - "github.com/attestantio/vouch/testing/logger" "github.com/rs/zerolog" "github.com/stretchr/testify/require" ) @@ -236,31 +234,3 @@ func TestService(t *testing.T) { }) } } - -func TestProposeNoRANDAOReveal(t *testing.T) { - ctx := context.Background() - capture := logger.NewLogCapture() - - chainTime, err := standardchaintime.New(ctx, - standardchaintime.WithLogLevel(zerolog.Disabled), - standardchaintime.WithGenesisTimeProvider(mock.NewGenesisTimeProvider(time.Now())), - standardchaintime.WithSlotDurationProvider(mock.NewSlotDurationProvider(12*time.Second)), - standardchaintime.WithSlotsPerEpochProvider(mock.NewSlotsPerEpochProvider(32)), - ) - require.NoError(t, err) - - s, err := standard.New(ctx, - standard.WithLogLevel(zerolog.TraceLevel), - standard.WithMonitor(nullmetrics.New(ctx)), - standard.WithProposalDataProvider(mock.NewBeaconBlockProposalProvider()), - standard.WithChainTime(chainTime), - standard.WithValidatingAccountsProvider(mockaccountmanager.NewValidatingAccountsProvider()), - standard.WithBeaconBlockSubmitter(mock.NewBeaconBlockSubmitter()), - standard.WithRANDAORevealSigner(mocksigner.New()), - standard.WithBeaconBlockSigner(mocksigner.New()), - ) - require.NoError(t, err) - - s.Propose(ctx, &beaconblockproposer.Duty{}) - capture.AssertHasEntry(t, "Missing RANDAO reveal") -}