diff --git a/api/eventsopts.go b/api/eventsopts.go index 8e98a84c..ecfee289 100644 --- a/api/eventsopts.go +++ b/api/eventsopts.go @@ -133,7 +133,7 @@ type VoluntaryExitEventHandlerFunc func(context.Context, *phase0.SignedVoluntary type DataColumnSidecarEventHandlerFunc func(context.Context, *apiv1.DataColumnSidecarEvent) // ExecutionPayloadEventHandlerFunc is the handler for execution_payload events. -type ExecutionPayloadEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadEnvelope) +type ExecutionPayloadEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) // ExecutionPayloadAvailableEventHandlerFunc is the handler for execution_payload_available events. type ExecutionPayloadAvailableEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadAvailableEvent) @@ -142,7 +142,7 @@ type ExecutionPayloadAvailableEventHandlerFunc func(context.Context, *apiv1.Exec type ExecutionPayloadBidEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadBid) // ExecutionPayloadGossipEventHandlerFunc is the handler for execution_payload_gossip events. -type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *gloas.SignedExecutionPayloadEnvelope) +type ExecutionPayloadGossipEventHandlerFunc func(context.Context, *apiv1.ExecutionPayloadEvent) // FastConfirmationEventHandlerFunc is the handler for fast_confirmation events. type FastConfirmationEventHandlerFunc func(context.Context, *apiv1.FastConfirmationEvent) diff --git a/api/v1/executionpayloadevent.go b/api/v1/executionpayloadevent.go new file mode 100644 index 00000000..31272d72 --- /dev/null +++ b/api/v1/executionpayloadevent.go @@ -0,0 +1,132 @@ +// 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 v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// ExecutionPayloadEvent is the data for the `execution_payload` and +// `execution_payload_gossip` EIP-7732 events. Both carry a flat summary of a +// revealed execution payload (not the full SignedExecutionPayloadEnvelope): the +// `execution_payload` event fires when the envelope is imported into the +// fork-choice store, and `execution_payload_gossip` when it passes gossip +// validation. ExecutionOptimistic is only present on the `execution_payload` +// event. +type ExecutionPayloadEvent struct { + Slot phase0.Slot + BuilderIndex uint64 + BlockHash phase0.Hash32 + BlockRoot phase0.Root + ExecutionOptimistic bool +} + +// executionPayloadEventJSON is the spec representation of the struct. +type executionPayloadEventJSON struct { + Slot string `json:"slot"` + BuilderIndex string `json:"builder_index"` + BlockHash string `json:"block_hash"` + BlockRoot string `json:"block_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +// MarshalJSON implements json.Marshaler. +func (e *ExecutionPayloadEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(&executionPayloadEventJSON{ + Slot: fmt.Sprintf("%d", e.Slot), + BuilderIndex: fmt.Sprintf("%d", e.BuilderIndex), + BlockHash: fmt.Sprintf("%#x", e.BlockHash), + BlockRoot: fmt.Sprintf("%#x", e.BlockRoot), + ExecutionOptimistic: e.ExecutionOptimistic, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +// +// Only the identifying slot and block root are required. builder_index and +// block_hash are parsed when present and validated for length, tolerating +// per-client field divergence (e.g. some clients emit an extra, non-spec +// state_root, which is ignored). +func (e *ExecutionPayloadEvent) UnmarshalJSON(input []byte) error { + var data executionPayloadEventJSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + e.Slot = phase0.Slot(slot) + + if data.BlockRoot == "" { + return errors.New("block root missing") + } + if err := decodeFixedBytes(e.BlockRoot[:], data.BlockRoot, rootLength, "block root"); err != nil { + return err + } + + if data.BuilderIndex != "" { + builderIndex, err := strconv.ParseUint(data.BuilderIndex, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for builder index") + } + e.BuilderIndex = builderIndex + } + + if data.BlockHash != "" { + if err := decodeFixedBytes(e.BlockHash[:], data.BlockHash, phase0.Hash32Length, "block hash"); err != nil { + return err + } + } + + e.ExecutionOptimistic = data.ExecutionOptimistic + + return nil +} + +// String returns a string version of the structure. +func (e *ExecutionPayloadEvent) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} + +// decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly +// wantLen bytes. +func decodeFixedBytes(dst []byte, value string, wantLen int, name string) error { + decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil { + return errors.Wrapf(err, "invalid value for %s", name) + } + if len(decoded) != wantLen { + return fmt.Errorf("incorrect length %d for %s", len(decoded), name) + } + copy(dst, decoded) + + return nil +} diff --git a/api/v1/executionpayloadevent_test.go b/api/v1/executionpayloadevent_test.go new file mode 100644 index 00000000..3ff5e168 --- /dev/null +++ b/api/v1/executionpayloadevent_test.go @@ -0,0 +1,99 @@ +// 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 v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestExecutionPayloadEventJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.executionPayloadEventJSON", + }, + { + name: "SlotMissing", + input: []byte(`{"builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52507aad249d4dfe1557f9031d5154c1c7b2a2e5819d0639f719725aa538af","execution_optimistic":true}`), + err: "slot missing", + }, + { + name: "BlockRootMissing", + input: []byte(`{"slot":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","execution_optimistic":true}`), + err: "block root missing", + }, + { + name: "BlockRootInvalidLength", + input: []byte(`{"slot":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52","execution_optimistic":true}`), + err: "incorrect length 2 for block root", + }, + { + // Real prysm/lighthouse-shape sample: no state_root, execution_optimistic present. + name: "GoodBare", + input: []byte(`{"slot":"91000","builder_index":"1","block_hash":"0xc8ac520b396aad76b4cf448aa4ff8ca9b4c1fe600e6416234849654e874db714","block_root":"0x2e52507aad249d4dfe1557f9031d5154c1c7b2a2e5819d0639f719725aa538af","execution_optimistic":true}`), + }, + { + // Real nimbus-shape sample: carries an extra, non-spec state_root that must be ignored. + name: "GoodWithNonSpecStateRoot", + input: []byte(`{"slot":"91894","builder_index":"18446744073709551615","block_hash":"0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6","block_root":"0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","execution_optimistic":true}`), + }, + { + // Gossip-shape sample: no execution_optimistic. + name: "GoodGossip", + input: []byte(`{"slot":"91001","builder_index":"1","block_hash":"0x45aaeada228ea83858022991d8e6e8a2d6492f58e60d5d4a30acc528fcd5063d","block_root":"0xe7b490b90ad5bcc30d933c88f0177df4df2eb32094036421e455737582880268"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.ExecutionPayloadEvent + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.NotEmpty(t, rt) + } + }) + } +} + +func TestExecutionPayloadEventValues(t *testing.T) { + input := []byte(`{"slot":"91894","builder_index":"18446744073709551615","block_hash":"0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6","block_root":"0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f","state_root":"0x0000000000000000000000000000000000000000000000000000000000000000","execution_optimistic":true}`) + + var res api.ExecutionPayloadEvent + require.NoError(t, json.Unmarshal(input, &res)) + + assert.Equal(t, uint64(91894), uint64(res.Slot)) + assert.Equal(t, uint64(18446744073709551615), res.BuilderIndex) + assert.Equal(t, "0x716b390040980aa80e891c4881b5f266812ca1fdbf14efc4ef048145b8d0edb6", res.BlockHash.String()) + assert.Equal(t, "0x957251f4a7a2b9713520a3f44d008a7814efaf5c63ff2cfe2ff908f28d09866f", res.BlockRoot.String()) + assert.True(t, res.ExecutionOptimistic) +} diff --git a/http/events.go b/http/events.go index 6289f53b..13e11bd5 100644 --- a/http/events.go +++ b/http/events.go @@ -715,9 +715,9 @@ func (*Service) handleExecutionPayloadEvent(ctx context.Context, opts *api.EventsOpts, ) { log := zerolog.Ctx(ctx) - data := &gloas.SignedExecutionPayloadEnvelope{} + data := &apiv1.ExecutionPayloadEvent{} - err := json.Unmarshal(msg.Data, data) + err := unmarshalVersionedEventData(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload event") @@ -771,7 +771,7 @@ func (*Service) handleExecutionPayloadBidEvent(ctx context.Context, log := zerolog.Ctx(ctx) data := &gloas.SignedExecutionPayloadBid{} - err := json.Unmarshal(msg.Data, data) + err := unmarshalVersionedEventData(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload bid event") @@ -796,9 +796,9 @@ func (*Service) handleExecutionPayloadGossipEvent(ctx context.Context, opts *api.EventsOpts, ) { log := zerolog.Ctx(ctx) - data := &gloas.SignedExecutionPayloadEnvelope{} + data := &apiv1.ExecutionPayloadEvent{} - err := json.Unmarshal(msg.Data, data) + err := unmarshalVersionedEventData(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse execution payload gossip event") @@ -818,6 +818,22 @@ func (*Service) handleExecutionPayloadGossipEvent(ctx context.Context, } } +// unmarshalVersionedEventData decodes an SSE event payload that the beacon-API +// spec wraps as {"version": "...", "data": {...}}. Some clients (Prysm) still +// send the bare unwrapped object, so that shape is accepted as a fallback. +func unmarshalVersionedEventData(raw []byte, v any) error { + var wrapper struct { + Version string `json:"version"` + Data json.RawMessage `json:"data"` + } + + if err := json.Unmarshal(raw, &wrapper); err == nil && len(wrapper.Data) > 0 && wrapper.Version != "" { + return json.Unmarshal(wrapper.Data, v) + } + + return json.Unmarshal(raw, v) +} + func (*Service) handlePayloadAttestationMessageEvent(ctx context.Context, msg *sse.Event, opts *api.EventsOpts, @@ -825,7 +841,7 @@ func (*Service) handlePayloadAttestationMessageEvent(ctx context.Context, log := zerolog.Ctx(ctx) data := &gloas.PayloadAttestationMessage{} - err := json.Unmarshal(msg.Data, data) + err := unmarshalVersionedEventData(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse payload attestation message event") @@ -852,7 +868,7 @@ func (*Service) handleProposerPreferencesEvent(ctx context.Context, log := zerolog.Ctx(ctx) data := &gloas.SignedProposerPreferences{} - err := json.Unmarshal(msg.Data, data) + err := unmarshalVersionedEventData(msg.Data, data) if err != nil { log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse proposer preferences event") diff --git a/http/events_versioned_test.go b/http/events_versioned_test.go new file mode 100644 index 00000000..f400a133 --- /dev/null +++ b/http/events_versioned_test.go @@ -0,0 +1,26 @@ +package http + +import ( + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Lighthouse (spec-compliant) wraps gloas SSE payloads in a versioned +// envelope; Prysm sends the bare object. Both must decode. +func TestUnmarshalVersionedEventData(t *testing.T) { + bare := `{"validator_index":"325","data":{"beacon_block_root":"0x5793a892cd76b015aaaaaf8251a3061bbdb79cb7547fd926208e9beffec67e96","slot":"54715","payload_present":true,"blob_data_available":true},"signature":"0x946643c1b1d601a969fa99680e7d27701ad7c98ab4569caa6d76ed523cc74c1aa81361737ca37e42a21ae659d390bf580166086ccb6b412ad9463eceb31a1a54ac2c9b1c5238181f2ea7cb85750d332763908746f6035bfad655228a4ce45440"}` + wrapped := `{"version":"gloas","data":` + bare + `}` + + for name, payload := range map[string]string{"bare": bare, "wrapped": wrapped} { + t.Run(name, func(t *testing.T) { + msg := &gloas.PayloadAttestationMessage{} + require.NoError(t, unmarshalVersionedEventData([]byte(payload), msg)) + assert.Equal(t, uint64(325), uint64(msg.ValidatorIndex)) + assert.Equal(t, uint64(54715), uint64(msg.Data.Slot)) + assert.True(t, msg.Data.PayloadPresent) + }) + } +} diff --git a/http/signedexecutionpayloadenvelope.go b/http/signedexecutionpayloadenvelope.go index 308e74c2..808fdec7 100644 --- a/http/signedexecutionpayloadenvelope.go +++ b/http/signedexecutionpayloadenvelope.go @@ -14,9 +14,11 @@ package http import ( + "bytes" "context" "errors" "fmt" + "maps" client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" @@ -42,6 +44,7 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, // All current envelope-bearing forks (Gloas, Heze) reuse the gloas // schema, so the wire bytes always parse into *gloas.SignedExecutionPayloadEnvelope. envelope := &gloas.SignedExecutionPayloadEnvelope{} + metadata := metadataFromHeaders(httpResponse.headers) switch httpResponse.contentType { case ContentTypeSSZ: @@ -54,9 +57,14 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) } case ContentTypeJSON: - if err := envelope.UnmarshalJSON(httpResponse.body); err != nil { + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), envelope) + if err != nil { return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) } + + envelope = decoded + + maps.Copy(metadata, jsonMetadata) default: return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) } @@ -66,7 +74,7 @@ func (s *Service) SignedExecutionPayloadEnvelope(ctx context.Context, Version: httpResponse.consensusVersion, Gloas: envelope, }, - Metadata: metadataFromHeaders(httpResponse.headers), + Metadata: metadata, }, nil } @@ -87,6 +95,7 @@ func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, } envelope := &all.SignedExecutionPayloadEnvelope{Version: httpResponse.consensusVersion} + metadata := metadataFromHeaders(httpResponse.headers) switch httpResponse.contentType { case ContentTypeSSZ: @@ -99,16 +108,21 @@ func (s *Service) AgnosticSignedExecutionPayloadEnvelope(ctx context.Context, return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) } case ContentTypeJSON: - if err := envelope.UnmarshalJSON(httpResponse.body); err != nil { + decoded, jsonMetadata, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), envelope) + if err != nil { return nil, errors.Join(fmt.Errorf("failed to decode %s signed execution payload envelope", httpResponse.consensusVersion), err) } + + envelope = decoded + + maps.Copy(metadata, jsonMetadata) default: return nil, fmt.Errorf("unhandled content type %v", httpResponse.contentType) } return &api.Response[*all.SignedExecutionPayloadEnvelope]{ Data: envelope, - Metadata: metadataFromHeaders(httpResponse.headers), + Metadata: metadata, }, nil }