diff --git a/app/eth2wrap/eth2wrap.go b/app/eth2wrap/eth2wrap.go index 23bcdb221..00f4eabc5 100644 --- a/app/eth2wrap/eth2wrap.go +++ b/app/eth2wrap/eth2wrap.go @@ -330,7 +330,7 @@ func incError(endpoint string) { // wrapError returns the error as a wrapped structured error. func wrapError(ctx context.Context, err error, label string, fields ...z.Field) error { // Decompose go-eth2-client http errors - if apiErr := new(eth2api.Error); errors.As(err, apiErr) { + if apiErr := new(eth2api.Error); errors.As(err, &apiErr) { err = errors.New("nok http response", z.Int("status_code", apiErr.StatusCode), z.Str("endpoint", apiErr.Endpoint), diff --git a/app/eth2wrap/eth2wrap_test.go b/app/eth2wrap/eth2wrap_test.go index 6687613f2..f6422b3db 100644 --- a/app/eth2wrap/eth2wrap_test.go +++ b/app/eth2wrap/eth2wrap_test.go @@ -5,6 +5,7 @@ package eth2wrap_test import ( "context" "encoding/json" + "fmt" "net" "net/http" "net/http/httptest" @@ -15,7 +16,9 @@ import ( "testing" "time" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2spec "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -217,6 +220,27 @@ func TestErrors(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "beacon api genesis_time: network operation error: :") }) + + t.Run("eth2api error", func(t *testing.T) { + bmock, err := beaconmock.New() + require.NoError(t, err) + bmock.SignedBeaconBlockFunc = func(_ context.Context, blockID string) (*eth2spec.VersionedSignedBeaconBlock, error) { + return nil, ð2api.Error{ + Method: http.MethodGet, + Endpoint: fmt.Sprintf("/eth/v2/beacon/blocks/%s", blockID), + StatusCode: http.StatusNotFound, + Data: []byte(fmt.Sprintf(`{"code":404,"message":"NOT_FOUND: beacon block at slot %s","stacktraces":[]}`, blockID)), + } + } + + eth2Cl, err := eth2wrap.Instrument(bmock) + require.NoError(t, err) + + _, err = eth2Cl.SignedBeaconBlock(ctx, ð2api.SignedBeaconBlockOpts{Block: "123"}) + log.Error(ctx, "See this error log for fields", err) + require.Error(t, err) + require.ErrorContains(t, err, "nok http response") + }) } func TestCtxCancel(t *testing.T) { diff --git a/app/eth2wrap/synthproposer.go b/app/eth2wrap/synthproposer.go index b049d5520..25b8601ba 100644 --- a/app/eth2wrap/synthproposer.go +++ b/app/eth2wrap/synthproposer.go @@ -20,9 +20,11 @@ import ( "github.com/attestantio/go-eth2-client/spec/capella" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" shuffle "github.com/protolambda/eth2-shuffle" + "go.uber.org/zap" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" ) const ( @@ -149,10 +151,8 @@ func (h *synthWrapper) syntheticProposal(ctx context.Context, slot eth2p0.Slot, } signed, err := h.Client.SignedBeaconBlock(ctx, opts) if err != nil { - if apiErr := new(eth2api.Error); errors.As(err, apiErr) { // Continue if block is not found in the given slot. - if apiErr.StatusCode == http.StatusNotFound { - continue - } + if fieldExists(err, zap.Int("status_code", http.StatusNotFound)) { + continue } return nil, err @@ -204,6 +204,34 @@ func (h *synthWrapper) syntheticProposal(ctx context.Context, slot eth2p0.Slot, return proposal, nil } +// fieldExists checks if the given field exists as part of the given error. +func fieldExists(err error, field zap.Field) bool { + type structErr interface { + Fields() []z.Field + } + + sterr, ok := err.(structErr) //nolint:errorlint + if !ok { + return false + } + + zfs := sterr.Fields() + var zapFs []zap.Field + for _, field := range zfs { + field(func(zp zap.Field) { + zapFs = append(zapFs, zp) + }) + } + + for _, zaps := range zapFs { + if zaps.Equals(field) { + return true + } + } + + return false +} + // fraction returns a fraction of the transactions in the block. // This is used to reduce the size of synthetic blocks to manageable levels. func fraction(transactions []bellatrix.Transaction) []bellatrix.Transaction { diff --git a/app/eth2wrap/synthproposer_test.go b/app/eth2wrap/synthproposer_test.go index e3bafeee2..3e051c61c 100644 --- a/app/eth2wrap/synthproposer_test.go +++ b/app/eth2wrap/synthproposer_test.go @@ -4,6 +4,8 @@ package eth2wrap_test import ( "context" + "fmt" + "net/http" "testing" eth2api "github.com/attestantio/go-eth2-client/api" @@ -161,3 +163,92 @@ func TestSynthProposer(t *testing.T) { <-done } + +func TestSynthProposerBlockNotFound(t *testing.T) { + ctx := context.Background() + + var ( + set = beaconmock.ValidatorSetA + feeRecipient = bellatrix.ExecutionAddress{0x00, 0x01, 0x02} + slotsPerEpoch = 3 + epoch eth2p0.Epoch = 1 + realBlockSlot = eth2p0.Slot(slotsPerEpoch) * eth2p0.Slot(epoch) + activeVals = 0 + timesCalled int + ) + + bmock, err := beaconmock.New(beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch)) + require.NoError(t, err) + + bmock.ProposerDutiesFunc = func(ctx context.Context, e eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + require.Equal(t, int(epoch), int(e)) + + return []*eth2v1.ProposerDuty{ // First validator is the proposer for first slot in the epoch. + { + PubKey: set[1].Validator.PublicKey, + Slot: realBlockSlot, + ValidatorIndex: set[1].Index, + }, + }, nil + } + cached := bmock.ActiveValidatorsFunc + bmock.ActiveValidatorsFunc = func(ctx context.Context) (eth2wrap.ActiveValidators, error) { + activeVals++ + return cached(ctx) + } + + // Return eth2api Error when SignedBeaconBlock is requested. + bmock.SignedBeaconBlockFunc = func(ctx context.Context, blockID string) (*eth2spec.VersionedSignedBeaconBlock, error) { + timesCalled++ + + return nil, ð2api.Error{ + Method: http.MethodGet, + Endpoint: fmt.Sprintf("/eth/v2/beacon/blocks/%s", blockID), + StatusCode: http.StatusNotFound, + Data: []byte(fmt.Sprintf(`{"code":404,"message":"NOT_FOUND: beacon block at slot %s","stacktraces":[]}`, blockID)), + } + } + + // Wrap beacon mock with multi eth2 client implementation which returns wrapped error. + eth2Cl, err := eth2wrap.Instrument(bmock) + require.NoError(t, err) + + eth2Cl = eth2wrap.WithSyntheticDuties(eth2Cl) + + var preps []*eth2v1.ProposalPreparation + for vIdx := range set { + preps = append(preps, ð2v1.ProposalPreparation{ + ValidatorIndex: vIdx, + FeeRecipient: feeRecipient, + }) + } + require.NoError(t, eth2Cl.SubmitProposalPreparations(ctx, preps)) + + // Get synthetic duties + opts := ð2api.ProposerDutiesOpts{ + Epoch: epoch, + Indices: nil, + } + resp1, err := eth2Cl.ProposerDuties(ctx, opts) + require.NoError(t, err) + duties := resp1.Data + require.Len(t, duties, len(set)) + require.Equal(t, 1, activeVals) + + // Submit blocks + for _, duty := range duties { + timesCalled = 0 + var graff [32]byte + copy(graff[:], "test") + opts1 := ð2api.ProposalOpts{ + Slot: duty.Slot, + RandaoReveal: testutil.RandomEth2Signature(), + Graffiti: graff, + } + _, err = eth2Cl.Proposal(ctx, opts1) + require.ErrorContains(t, err, "no proposal found to base synthetic proposal on") + + // SignedBeaconBlock will be called for previous slots starting from duty.Slot-1 upto slot 0 (exclusive). + require.Equal(t, timesCalled, int(duty.Slot)-1) + } +}