Skip to content
Open
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
5 changes: 5 additions & 0 deletions api/eventsopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type EventsOpts struct {
FinalizedCheckpointHandler FinalizedCheckpointEventHandlerFunc
// HeadHandler is a handler for the head event.
HeadHandler HeadEventHandlerFunc
// HeadV2Handler is a handler for the head_v2 event.
HeadV2Handler HeadV2EventHandlerFunc
// PayloadAttestationMessageHandler is a handler for the payload_attestation_message event.
PayloadAttestationMessageHandler PayloadAttestationMessageEventHandlerFunc
// PayloadAttributesHandler is a handler for the payload_attributes event.
Expand Down Expand Up @@ -117,6 +119,9 @@ type FinalizedCheckpointEventHandlerFunc func(context.Context, *apiv1.FinalizedC
// HeadEventHandlerFunc is the handler for head events.
type HeadEventHandlerFunc func(context.Context, *apiv1.HeadEvent)

// HeadV2EventHandlerFunc is the handler for head_v2 events.
type HeadV2EventHandlerFunc func(context.Context, *apiv1.HeadEventV2)

// PayloadAttributesEventHandlerFunc is the handler for payload_attributes events.
type PayloadAttributesEventHandlerFunc func(context.Context, *apiv1.PayloadAttributesEvent)

Expand Down
3 changes: 3 additions & 0 deletions api/v1/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var SupportedEventTopics = map[string]bool{
"fast_confirmation": true,
"finalized_checkpoint": true,
"head": true,
"head_v2": true,
"inclusion_list": true,
"payload_attestation_message": true,
"payload_attributes": true,
Expand Down Expand Up @@ -136,6 +137,8 @@ func (e *Event) UnmarshalJSON(input []byte) error {
e.Data = &FinalizedCheckpointEvent{}
case "head":
e.Data = &HeadEvent{}
case "head_v2":
e.Data = &HeadEventV2{}
case "payload_attestation_message":
e.Data = &gloas.PayloadAttestationMessage{}
case "payload_attributes":
Expand Down
2 changes: 2 additions & 0 deletions api/v1/executionpayloadevent.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ func (e *ExecutionPayloadEvent) String() string {

// decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly
// wantLen bytes.
//
//nolint:unparam // every current field is 32 bytes; wantLen keeps the helper usable for 20-byte addresses et al.
func decodeFixedBytes(dst []byte, value string, wantLen int, name string) error {
decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x"))
if err != nil {
Expand Down
141 changes: 141 additions & 0 deletions api/v1/headeventv2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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 (
"bytes"
"encoding/json"
"fmt"
"strconv"

"github.com/ethpandaops/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
)

// HeadEventV2 is the data for the head_v2 event (Gloas / EIP-7732).
//
// Relative to HeadEvent it drops previous_duty_dependent_root, renames
// current_duty_dependent_root to current_epoch_dependent_root, and adds
// next_epoch_dependent_root plus payload_status. The event can be emitted
// twice for the same block when payload_status transitions from empty to
// full.
type HeadEventV2 struct {
Slot phase0.Slot
Block phase0.Root
State phase0.Root
PayloadStatus string
EpochTransition bool
CurrentEpochDependentRoot phase0.Root
NextEpochDependentRoot phase0.Root
ExecutionOptimistic bool
}

// headEventV2JSON is the spec representation of the struct.
type headEventV2JSON struct {
Slot string `json:"slot"`
Block string `json:"block"`
State string `json:"state"`
PayloadStatus string `json:"payload_status,omitempty"`
EpochTransition bool `json:"epoch_transition"`
CurrentEpochDependentRoot string `json:"current_epoch_dependent_root,omitempty"`
NextEpochDependentRoot string `json:"next_epoch_dependent_root,omitempty"`
ExecutionOptimistic bool `json:"execution_optimistic"`
}

// MarshalJSON implements json.Marshaler.
func (e *HeadEventV2) MarshalJSON() ([]byte, error) {
data := &headEventV2JSON{
Slot: fmt.Sprintf("%d", e.Slot),
Block: fmt.Sprintf("%#x", e.Block),
State: fmt.Sprintf("%#x", e.State),
PayloadStatus: e.PayloadStatus,
EpochTransition: e.EpochTransition,
ExecutionOptimistic: e.ExecutionOptimistic,
}

var zeroRoot phase0.Root
if !bytes.Equal(zeroRoot[:], e.CurrentEpochDependentRoot[:]) {
data.CurrentEpochDependentRoot = fmt.Sprintf("%#x", e.CurrentEpochDependentRoot)
}

if !bytes.Equal(zeroRoot[:], e.NextEpochDependentRoot[:]) {
data.NextEpochDependentRoot = fmt.Sprintf("%#x", e.NextEpochDependentRoot)
}

return json.Marshal(data)
}

// UnmarshalJSON implements json.Unmarshaler.
func (e *HeadEventV2) UnmarshalJSON(input []byte) error {
var headEventV2JSON headEventV2JSON
if err := json.Unmarshal(input, &headEventV2JSON); err != nil {
return errors.Wrap(err, "invalid JSON")
}

if headEventV2JSON.Slot == "" {
return errors.New("slot missing")
}

slot, err := strconv.ParseUint(headEventV2JSON.Slot, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid value for slot")
}

e.Slot = phase0.Slot(slot)

if headEventV2JSON.Block == "" {
return errors.New("block missing")
}

if err := decodeFixedBytes(e.Block[:], headEventV2JSON.Block, rootLength, "block"); err != nil {
return err
}

if headEventV2JSON.State == "" {
return errors.New("state missing")
}

if err := decodeFixedBytes(e.State[:], headEventV2JSON.State, rootLength, "state"); err != nil {
return err
}

e.PayloadStatus = headEventV2JSON.PayloadStatus
e.EpochTransition = headEventV2JSON.EpochTransition
e.ExecutionOptimistic = headEventV2JSON.ExecutionOptimistic

// Dependent roots only have partial client coverage so do not complain if not present.
if headEventV2JSON.CurrentEpochDependentRoot != "" {
if err := decodeFixedBytes(e.CurrentEpochDependentRoot[:], headEventV2JSON.CurrentEpochDependentRoot, rootLength, "current epoch dependent root"); err != nil {
return err
}
}

if headEventV2JSON.NextEpochDependentRoot != "" {
if err := decodeFixedBytes(e.NextEpochDependentRoot[:], headEventV2JSON.NextEpochDependentRoot, rootLength, "next epoch dependent root"); err != nil {
return err
}
}

return nil
}

// String returns a string version of the structure.
func (e *HeadEventV2) String() string {
data, err := json.Marshal(e)
if err != nil {
return fmt.Sprintf("ERR: %v", err)
}

return string(data)
}
98 changes: 98 additions & 0 deletions api/v1/headeventv2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 TestHeadEventV2JSON(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.headEventV2JSON",
},
{
name: "SlotMissing",
input: []byte(`{"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`),
err: "slot missing",
},
{
name: "SlotInvalid",
input: []byte(`{"slot":"-1","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`),
err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax",
},
{
name: "BlockMissing",
input: []byte(`{"slot":"525277","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`),
err: "block missing",
},
{
name: "BlockWrongLength",
input: []byte(`{"slot":"525277","block":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`),
err: "incorrect length 31 for block",
},
{
name: "StateMissing",
input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`),
err: "state missing",
},
{
name: "DependentRootsMissing",
input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":false,"execution_optimistic":false}`),
},
{
name: "CurrentEpochDependentRootWrongLength",
input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x7a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`),
err: "incorrect length 31 for current epoch dependent root",
},
{
name: "Good",
input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`),
},
{
name: "GoodEpochTransition",
input: []byte(`{"slot":"525280","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":true,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":true}`),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var res api.HeadEventV2
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.Equal(t, string(test.input), string(rt))
}
})
}
}
31 changes: 31 additions & 0 deletions http/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ func (*Service) checkEventSpecificHandler(opts *api.EventsOpts, topic string) er
hasHandler = opts.FinalizedCheckpointHandler != nil
case "head":
hasHandler = opts.HeadHandler != nil
case "head_v2":
hasHandler = opts.HeadV2Handler != nil
case "payload_attestation_message":
hasHandler = opts.PayloadAttestationMessageHandler != nil
case "payload_attributes":
Expand Down Expand Up @@ -229,6 +231,8 @@ func (s *Service) handleEvent(ctx context.Context,
s.handleFinalizedCheckpointEvent(ctx, msg, opts)
case "head":
s.handleHeadEvent(ctx, msg, opts)
case "head_v2":
s.handleHeadV2Event(ctx, msg, opts)
case "payload_attestation_message":
s.handlePayloadAttestationMessageEvent(ctx, msg, opts)
case "payload_attributes":
Expand Down Expand Up @@ -548,6 +552,33 @@ func (*Service) handleFinalizedCheckpointEvent(ctx context.Context,
}
}

func (*Service) handleHeadV2Event(ctx context.Context,
msg *sse.Event,
opts *api.EventsOpts,
) {
log := zerolog.Ctx(ctx)
data := &apiv1.HeadEventV2{}

err := unmarshalVersionedEventData(msg.Data, data)
if err != nil {
log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head_v2 event")

return
}

switch {
case opts.HeadV2Handler != nil:
opts.HeadV2Handler(ctx, data)
case opts.Handler != nil:
opts.Handler(&apiv1.Event{
Topic: string(msg.Event),
Data: data,
})
default:
log.Debug().Msg("No specific or generic handler supplied; ignoring")
}
}

func (*Service) handleHeadEvent(ctx context.Context,
msg *sse.Event,
opts *api.EventsOpts,
Expand Down
Loading
Loading