From 3508a16829bf05c89aaae4f9b54e30447d927a57 Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Thu, 2 Jul 2026 16:46:19 +1000 Subject: [PATCH 1/3] fix(http): unwrap JSON data envelope for execution payload envelopes The JSON path of SignedExecutionPayloadEnvelope and AgnosticSignedExecutionPayloadEnvelope passed the raw response body to UnmarshalJSON, which fails with "message missing" because beacon nodes wrap the payload as {"version":...,"data":{...}}. Use decodeJSONResponse like the other endpoints so the data field is extracted (and response metadata preserved). SSZ responses were unaffected, which hid the bug from SSZ-preferring clients. --- http/signedexecutionpayloadenvelope.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 } From af3297cb0c0d5763a4051282b0c48c9df64e290e Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Fri, 3 Jul 2026 11:56:00 +1000 Subject: [PATCH 2/3] fix(http): accept versioned wrapper on gloas SSE events The beacon-API eventstream spec wraps payload_attestation_message, execution_payload_bid and proposer_preferences as {"version": ..., "data": {...}}. Lighthouse follows the spec; Prysm still sends the bare object. Decode the wrapper when present and fall back to the bare shape, so both clients parse. Observed live on glamsterdam-devnet-6: every lighthouse PTC message failed with 'invalid JSON: beacon block root missing'. --- http/events.go | 23 ++++++++++++++++++++--- http/events_versioned_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 http/events_versioned_test.go diff --git a/http/events.go b/http/events.go index 6289f53b..72fa8b59 100644 --- a/http/events.go +++ b/http/events.go @@ -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") @@ -818,6 +818,23 @@ 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 +842,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 +869,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) + }) + } +} From ed85bf5c3bab76a637f23ab35fd7c6d18b7fcf72 Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Wed, 8 Jul 2026 16:13:30 +1000 Subject: [PATCH 3/3] feat(http): parse flat execution_payload / _gossip SSE events The EIP-7732 `execution_payload` and `execution_payload_gossip` event-stream events carry a flat summary of a revealed payload (per beacon-APIs apis/eventstream/index.yaml), not the full SignedExecutionPayloadEnvelope: {"slot","builder_index","block_hash","block_root","execution_optimistic"} The handlers were unmarshalling the data into gloas.SignedExecutionPayloadEnvelope, whose UnmarshalJSON requires a `message` field, so every event failed with "message missing" and was dropped (observed across all 6 CL clients on the glamsterdam devnet). Add a spec-faithful ExecutionPayloadEvent type and decode both topics into it, tolerating the {version,data} wrapper some clients emit and an extra, non-spec state_root field (nimbus). Change the two handler func types accordingly. Claude-Session: https://claude.ai/code/session_014KRVmYwRSsK5pFbghweigM --- api/eventsopts.go | 4 +- api/v1/executionpayloadevent.go | 132 +++++++++++++++++++++++++++ api/v1/executionpayloadevent_test.go | 99 ++++++++++++++++++++ http/events.go | 9 +- 4 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 api/v1/executionpayloadevent.go create mode 100644 api/v1/executionpayloadevent_test.go 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 72fa8b59..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") @@ -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,7 +818,6 @@ 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.