Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/eventsopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
132 changes: 132 additions & 0 deletions api/v1/executionpayloadevent.go
Original file line number Diff line number Diff line change
@@ -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
}
99 changes: 99 additions & 0 deletions api/v1/executionpayloadevent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
30 changes: 23 additions & 7 deletions http/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand All @@ -818,14 +818,30 @@ 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,
) {
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")

Expand All @@ -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")

Expand Down
26 changes: 26 additions & 0 deletions http/events_versioned_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading