Repository: ObolNetwork/charon

Issue:
Track SSE events `block_gossip` and `block`
# 🎯 Problem to be solved
We currently track head updates, which occur once new head is chosen (>=2/3 slot has passed = a new head was chosen), however, we would like to also track how fast the beacon node received the initial block proposed (<= 1/3 slot has passed).

# 🛠️ Proposed solution
- [ ] Subscribe to `block_gossip` and `block` SSE events
- [ ] Log in the same way the received block

# 🧪 Tests
- [ ] Manually tested on local compose simnet


Hints from issue comments:

Solution patch:
diff --git a/app/eth2wrap/synthproposer.go b/app/eth2wrap/synthproposer.go
index 73b710db..31806f5e 100644
--- a/app/eth2wrap/synthproposer.go
+++ b/app/eth2wrap/synthproposer.go
@@ -15,6 +15,7 @@ import (
 	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
@@ -186,7 +187,7 @@ func (h *synthWrapper) syntheticProposal(ctx context.Context, slot eth2p0.Slot,
 		proposal.Electra.Block.Body.ExecutionPayload.FeeRecipient = feeRecipient
 		proposal.Electra.Block.Body.ExecutionPayload.Transactions = fraction(proposal.Electra.Block.Body.ExecutionPayload.Transactions)
 	case eth2spec.DataVersionFulu:
-		proposal.Fulu = &eth2electra.BlockContents{}
+		proposal.Fulu = &eth2fulu.BlockContents{}
 		proposal.Fulu.Block = signedBlock.Fulu.Message
 		proposal.Fulu.Block.Body.Graffiti = GetSyntheticGraffiti()
 		proposal.Fulu.Block.Slot = slot
diff --git a/app/sse/client.go b/app/sse/client.go
index b6b9f115..c178a9a6 100644
--- a/app/sse/client.go
+++ b/app/sse/client.go
@@ -58,6 +58,8 @@ func newClient(addr string, header http.Header) (*client, error) {
 	q := u.Query()
 	q.Add("topics", sseHeadEvent)
 	q.Add("topics", sseChainReorgEvent)
+	q.Add("topics", sseBlockGossipEvent)
+	q.Add("topics", sseBlockEvent)
 	u.RawQuery = q.Encode()
 
 	return &client{
@@ -178,9 +180,7 @@ func (c *client) connect(ctx context.Context, eventFn EventHandler) error {
 }
 
 func (c *client) parseEvent(r *bufio.Reader) (*event, error) {
-	event := &event{
-		Timestamp: time.Now(),
-	}
+	event := &event{}
 
 	for {
 		parts, err := formatAndValidateEvent(r)
@@ -204,6 +204,7 @@ func (c *client) parseEvent(r *bufio.Reader) (*event, error) {
 		case "id":
 			event.ID = string(parts[1])
 		case "event":
+			event.Timestamp = time.Now()
 			event.Event = string(parts[1])
 		case "data":
 			if event.Data != nil {
diff --git a/app/sse/listener.go b/app/sse/listener.go
index 048a44f9..78b6ce4f 100644
--- a/app/sse/listener.go
+++ b/app/sse/listener.go
@@ -100,6 +100,10 @@ func (p *listener) eventHandler(ctx context.Context, event *event, addr string)
 		return p.handleHeadEvent(ctx, event, addr)
 	case sseChainReorgEvent:
 		return p.handleChainReorgEvent(ctx, event, addr)
+	case sseBlockGossipEvent:
+		return p.handleBlockGossipEvent(ctx, event, addr)
+	case sseBlockEvent:
+		return p.handleBlockEvent(ctx, event, addr)
 	default:
 		return nil
 	}
@@ -122,7 +126,11 @@ func (p *listener) handleHeadEvent(ctx context.Context, event *event, addr strin
 		return errors.New("slot value exceeds int64 range", z.Str("addr", addr), z.U64("slot", slot))
 	}
 
-	delay, ok := p.computeDelay(slot, event.Timestamp)
+	delay, ok := p.computeDelay(slot, event.Timestamp, func(delay time.Duration) bool {
+		// Chain's head is updated upon majority of the chain voting with attestations for a block.
+		// Realistically this happens between 2/3 and 3/3 of the slot's timeframe.
+		return delay < p.slotDuration
+	})
 	if !ok {
 		log.Debug(ctx, "Beacon node received head event too late", z.U64("slot", slot), z.Str("delay", delay.String()))
 	} else {
@@ -142,7 +150,7 @@ func (p *listener) handleHeadEvent(ctx context.Context, event *event, addr strin
 }
 
 func (p *listener) handleChainReorgEvent(ctx context.Context, event *event, addr string) error {
-	var chainReorg chainReorgData
+	var chainReorg chainReorgEventData
 
 	err := json.Unmarshal(event.Data, &chainReorg)
 	if err != nil {
@@ -180,6 +188,68 @@ func (p *listener) handleChainReorgEvent(ctx context.Context, event *event, addr
 	return nil
 }
 
+func (p *listener) handleBlockGossipEvent(ctx context.Context, event *event, addr string) error {
+	var blockGossip blockGossipEventData
+
+	err := json.Unmarshal(event.Data, &blockGossip)
+	if err != nil {
+		return errors.Wrap(err, "unmarshal SSE block_gossip event", z.Str("addr", addr))
+	}
+
+	slot, err := strconv.ParseUint(blockGossip.Slot, 10, 64)
+	if err != nil {
+		return errors.Wrap(err, "parse slot to uint64", z.Str("addr", addr))
+	}
+
+	delay, ok := p.computeDelay(slot, event.Timestamp, func(delay time.Duration) bool {
+		// Beacon node should receive a block via P2P or API between 0/3 and 1/3 of the slot's timeframe.
+		return delay < (p.slotDuration / 3)
+	})
+	if !ok {
+		log.Debug(ctx, "Beacon node received block_gossip event too late", z.U64("slot", slot), z.Str("delay", delay.String()))
+	}
+
+	log.Debug(ctx, "SSE block gossip event",
+		z.U64("slot", slot),
+		z.Str("delay", delay.String()),
+		z.Str("block", blockGossip.Block))
+
+	sseBlockGossipHistogram.WithLabelValues(addr).Observe(float64(delay))
+
+	return nil
+}
+
+func (p *listener) handleBlockEvent(ctx context.Context, event *event, addr string) error {
+	var block blockEventData
+
+	err := json.Unmarshal(event.Data, &block)
+	if err != nil {
+		return errors.Wrap(err, "unmarshal SSE block event", z.Str("addr", addr))
+	}
+
+	slot, err := strconv.ParseUint(block.Slot, 10, 64)
+	if err != nil {
+		return errors.Wrap(err, "parse slot to uint64", z.Str("addr", addr))
+	}
+
+	delay, ok := p.computeDelay(slot, event.Timestamp, func(delay time.Duration) bool {
+		// Beacon node should import a block to its fork-choice between 0/3 and 1/3 of the slot's timeframe.
+		return delay < (p.slotDuration / 3)
+	})
+	if !ok {
+		log.Debug(ctx, "Beacon node received block event too late", z.U64("slot", slot), z.Str("delay", delay.String()))
+	}
+
+	log.Debug(ctx, "SSE block event",
+		z.U64("slot", slot),
+		z.Str("delay", delay.String()),
+		z.Str("block", block.Block))
+
+	sseBlockHistogram.WithLabelValues(addr).Observe(float64(delay))
+
+	return nil
+}
+
 func (p *listener) notifyChainReorg(ctx context.Context, epoch eth2p0.Epoch) {
 	p.Lock()
 	defer p.Unlock()
@@ -194,14 +264,11 @@ func (p *listener) notifyChainReorg(ctx context.Context, epoch eth2p0.Epoch) {
 	}
 }
 
-// Compute delay between start of the slot and receiving the head update event.
-func (p *listener) computeDelay(slot uint64, eventTS time.Time) (time.Duration, bool) {
+// Compute delay between start of the slot and receiving the event.
+func (p *listener) computeDelay(slot uint64, eventTS time.Time, delayOKFunc func(delay time.Duration) bool) (time.Duration, bool) {
 	slotStartTime := p.genesisTime.Add(time.Duration(slot) * p.slotDuration)
 	delay := eventTS.Sub(slotStartTime)
-	// Chain's head is updated upon majority of the chain voting with attestations for a block.
-	// Realistically this happens between 2/3 and 3/3 of the slot's timeframe.
-	delayOK := delay < p.slotDuration
 
 	// calculate time of receiving the event - the time of start of the slot
-	return delay + p.slotDuration, delayOK
+	return delay, delayOKFunc(delay)
 }
diff --git a/app/sse/metrics.go b/app/sse/metrics.go
index 6877875e..cabba420 100644
--- a/app/sse/metrics.go
+++ b/app/sse/metrics.go
@@ -21,7 +21,7 @@ var (
 		Subsystem: "beacon_node",
 		Name:      "sse_head_delay",
 		Help:      "Delay in seconds between slot start and head update, supplied by beacon node's SSE endpoint. Values between 8s and 12s for Ethereum mainnet are considered safe.",
-		Buckets:   []float64{4, 6, 8, 10, 12, 16, 20},
+		Buckets:   []float64{2, 4, 6, 8, 10, 12},
 	}, []string{"addr"})
 
 	sseChainReorgDepthHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{
@@ -31,4 +31,20 @@ var (
 		Help:      "Chain reorg depth, supplied by beacon node's SSE endpoint",
 		Buckets:   []float64{1, 2, 4, 6, 8, 16},
 	}, []string{"addr"})
+
+	sseBlockGossipHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{
+		Namespace: "app",
+		Subsystem: "beacon_node",
+		Name:      "sse_block_gossip",
+		Help:      "Block reception via gossip delay, supplied by beacon node's SSE endpoint. Values between 0s and 4s for Ethereum mainnet are considered safe",
+		Buckets:   []float64{0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 8, 10, 12},
+	}, []string{"addr"})
+
+	sseBlockHistogram = promauto.NewHistogramVec(prometheus.HistogramOpts{
+		Namespace: "app",
+		Subsystem: "beacon_node",
+		Name:      "sse_block",
+		Help:      "Block imported into fork choice delay, supplied by beacon node's SSE endpoint. Values between 0s and 4s for Ethereum mainnet are considered safe",
+		Buckets:   []float64{0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 8, 10, 12},
+	}, []string{"addr"})
 )
diff --git a/app/sse/types.go b/app/sse/types.go
index 4bebb657..6ae813a4 100644
--- a/app/sse/types.go
+++ b/app/sse/types.go
@@ -3,8 +3,10 @@
 package sse
 
 const (
-	sseHeadEvent       = "head"
-	sseChainReorgEvent = "chain_reorg"
+	sseHeadEvent        = "head"
+	sseChainReorgEvent  = "chain_reorg"
+	sseBlockGossipEvent = "block_gossip"
+	sseBlockEvent       = "block"
 )
 
 type headEventData struct {
@@ -17,7 +19,7 @@ type headEventData struct {
 	ExecutionOptimistic       bool   `json:"execution_optimistic"`
 }
 
-type chainReorgData struct {
+type chainReorgEventData struct {
 	Slot                string `json:"slot"`
 	Depth               string `json:"depth"`
 	OldHeadBlock        string `json:"old_head_block"`
@@ -27,3 +29,14 @@ type chainReorgData struct {
 	Epoch               string `json:"epoch"`
 	ExecutionOptimistic bool   `json:"execution_optimistic"`
 }
+
+type blockGossipEventData struct {
+	Slot  string `json:"slot"`
+	Block string `json:"block"`
+}
+
+type blockEventData struct {
+	Slot                string `json:"slot"`
+	Block               string `json:"block"`
+	ExecutionOptimistic bool   `json:"execution_optimistic"`
+}
diff --git a/core/consensus/qbft/qbft.go b/core/consensus/qbft/qbft.go
index 75a3854e..ea1c5eb9 100644
--- a/core/consensus/qbft/qbft.go
+++ b/core/consensus/qbft/qbft.go
@@ -370,6 +370,7 @@ func (c *Consensus) runInstance(parent context.Context, duty core.Duty) (err err
 	defer cancel()
 
 	log.Debug(ctx, "QBFT consensus instance starting",
+		z.Any("peer", p2p.PeerName(c.p2pNode.ID())),
 		z.Any("peers", c.peerLabels),
 		z.Any("timer", string(roundTimer.Type())),
 	)
diff --git a/core/signeddata.go b/core/signeddata.go
index 40c897f9..3c2a255d 100644
--- a/core/signeddata.go
+++ b/core/signeddata.go
@@ -12,6 +12,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -579,7 +580,7 @@ func (p *VersionedSignedProposal) UnmarshalJSON(input []byte) error {
 
 			resp.FuluBlinded = block
 		} else {
-			block := new(eth2electra.SignedBlockContents) // Fulu blocks have the same structure as electra blocks.
+			block := new(eth2fulu.SignedBlockContents)
 			if err := json.Unmarshal(raw.Block, &block); err != nil {
 				return errors.Wrap(err, "unmarshal fulu")
 			}
diff --git a/core/ssz.go b/core/ssz.go
index 2d101258..46abe269 100644
--- a/core/ssz.go
+++ b/core/ssz.go
@@ -10,6 +10,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
 	"github.com/attestantio/go-eth2-client/spec/capella"
@@ -154,7 +155,7 @@ func (p *VersionedSignedProposal) sszValFromVersion(version eth2util.DataVersion
 		return p.Electra, nil
 	case eth2util.DataVersionFulu:
 		if p.Fulu == nil && !blinded {
-			p.Fulu = new(eth2electra.SignedBlockContents) // Fulu blocks have the same structure as electra blocks.
+			p.Fulu = new(eth2fulu.SignedBlockContents)
 		}
 
 		if p.FuluBlinded == nil && blinded {
@@ -296,7 +297,7 @@ func (p *VersionedProposal) sszValFromVersion(version eth2util.DataVersion, blin
 		return p.Electra, nil
 	case eth2util.DataVersionFulu:
 		if p.Fulu == nil && !blinded {
-			p.Fulu = new(eth2electra.BlockContents) // Fulu blocks have the same structure as electra blocks.
+			p.Fulu = new(eth2fulu.BlockContents)
 		}
 
 		if p.FuluBlinded == nil && blinded {
diff --git a/core/unsigneddata.go b/core/unsigneddata.go
index 9da86267..8cdbceef 100644
--- a/core/unsigneddata.go
+++ b/core/unsigneddata.go
@@ -11,6 +11,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -593,7 +594,7 @@ func (p *VersionedProposal) UnmarshalJSON(input []byte) error {
 
 			resp.FuluBlinded = block
 		} else {
-			block := new(eth2electra.BlockContents) // Fulu blocks have the same structure as electra blocks.
+			block := new(eth2fulu.BlockContents)
 			if err := json.Unmarshal(raw.Block, &block); err != nil {
 				return errors.Wrap(err, "unmarshal fulu")
 			}
diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go
index 25862814..17f69dfe 100644
--- a/core/validatorapi/router.go
+++ b/core/validatorapi/router.go
@@ -28,6 +28,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -1155,7 +1156,7 @@ func submitProposal(p eth2client.ProposalSubmitter) handlerFunc {
 			})
 
 		case eth2spec.DataVersionFulu:
-			fuluBlock := new(eth2electra.SignedBlockContents) // Fulu blocks have the same structure as electra blocks.
+			fuluBlock := new(eth2fulu.SignedBlockContents)
 
 			err = unmarshal(typ, body, fuluBlock)
 			if err != nil {
diff --git a/docs/metrics.md b/docs/metrics.md
index 5833a5cd..dec4f362 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -14,6 +14,8 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance.
 | Name | Type | Help | Labels |
 |---|---|---|---|
 | `app_beacon_node_peers` | Gauge | Gauge set to the peer count of the upstream beacon node |  |
+| `app_beacon_node_sse_block` | Histogram | Block imported into fork choice delay, supplied by beacon node`s SSE endpoint. Values between 0s and 4s for Ethereum mainnet are considered safe | `addr` |
+| `app_beacon_node_sse_block_gossip` | Histogram | Block reception via gossip delay, supplied by beacon node`s SSE endpoint. Values between 0s and 4s for Ethereum mainnet are considered safe | `addr` |
 | `app_beacon_node_sse_chain_reorg_depth` | Histogram | Chain reorg depth, supplied by beacon node`s SSE endpoint | `addr` |
 | `app_beacon_node_sse_head_delay` | Histogram | Delay in seconds between slot start and head update, supplied by beacon node`s SSE endpoint. Values between 8s and 12s for Ethereum mainnet are considered safe. | `addr` |
 | `app_beacon_node_sse_head_slot` | Gauge | Current beacon node head slot, supplied by beacon node`s SSE endpoint | `addr` |
diff --git a/go.mod b/go.mod
index 29d29809..f4e26a36 100644
--- a/go.mod
+++ b/go.mod
@@ -294,7 +294,7 @@ require (
 replace github.com/coinbase/kryptology => github.com/ObolNetwork/kryptology v0.1.0
 
 // We're replacing go-eth2-client with a branch off our fork. The branch is kept up to date with the latest attestantio versions.
-replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.2 //nolint
+replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.3 //nolint
 
 tool (
 	github.com/bufbuild/buf/cmd/buf
diff --git a/go.sum b/go.sum
index 32134946..fe887ff4 100644
--- a/go.sum
+++ b/go.sum
@@ -38,8 +38,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
 github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.2 h1:MhCttpP6a/Sa/TbnuXYCAtV3v5DfxgJ9UvH7/8+Tn40=
-github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.2/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY=
+github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.3 h1:OLxybxrE7d4QTzkzq5kaHlJ003hXk84iuZzvQHBhSvY=
+github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2-fulu.3/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY=
 github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI=
 github.com/ObolNetwork/kryptology v0.1.0/go.mod h1:/Wl7Js2f676GyXZDTaojf/O+l0fxFPWudbyjdFhkpSA=
 github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=



Test patch:
diff --git a/app/sse/listener_internal_test.go b/app/sse/listener_internal_test.go
index 551c54aa..1f03f7a9 100644
--- a/app/sse/listener_internal_test.go
+++ b/app/sse/listener_internal_test.go
@@ -151,14 +151,14 @@ func TestComputeDelay(t *testing.T) {
 			name:       "happy path",
 			slot:       1,
 			eventTS:    genesisTime.Add(slotDuration + 2*slotDuration/3), // 2/3 into slot 1
-			expected:   2*slotDuration/3 + slotDuration,
+			expected:   2 * slotDuration / 3,
 			expectedOk: true,
 		},
 		{
 			name:       "happy path, not ok",
 			slot:       1,
 			eventTS:    genesisTime.Add(2*slotDuration + time.Second), // 1 second into slot 2
-			expected:   2*slotDuration + time.Second,
+			expected:   slotDuration + time.Second,
 			expectedOk: false,
 		},
 	}
@@ -171,7 +171,9 @@ func TestComputeDelay(t *testing.T) {
 				slotsPerEpoch: 32,
 			}
 
-			res, ok := l.computeDelay(test.slot, test.eventTS)
+			res, ok := l.computeDelay(test.slot, test.eventTS, func(delay time.Duration) bool {
+				return delay < slotDuration
+			})
 			require.Equal(t, test.expected, res)
 			require.Equal(t, test.expectedOk, ok)
 		})
diff --git a/core/consensus/qbft/qbft_internal_test.go b/core/consensus/qbft/qbft_internal_test.go
index ed479de7..d52e6139 100644
--- a/core/consensus/qbft/qbft_internal_test.go
+++ b/core/consensus/qbft/qbft_internal_test.go
@@ -520,6 +520,7 @@ func TestInstanceIO_MaybeStart(t *testing.T) {
 		p2pKey := testutil.GenerateInsecureK1Key(t, 0)
 		c.pubkeys = make(map[int64]*k1.PublicKey)
 		c.pubkeys[0] = p2pKey.PubKey()
+		c.p2pNode = testutil.CreateHost(t, testutil.AvailableAddr(t))
 
 		duty := core.Duty{Slot: 42, Type: 1}
 		msg := &pbv1.QBFTConsensusMsg{
diff --git a/core/signeddata_test.go b/core/signeddata_test.go
index feecc701..57e65f1b 100644
--- a/core/signeddata_test.go
+++ b/core/signeddata_test.go
@@ -12,6 +12,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -170,7 +171,7 @@ func TestSignedDataSetSignature(t *testing.T) {
 			data: core.VersionedSignedProposal{
 				VersionedSignedProposal: eth2api.VersionedSignedProposal{
 					Version: eth2spec.DataVersionFulu,
-					Fulu: &eth2electra.SignedBlockContents{
+					Fulu: &eth2fulu.SignedBlockContents{
 						SignedBlock: &electra.SignedBeaconBlock{
 							Message:   testutil.RandomElectraBeaconBlock(),
 							Signature: testutil.RandomEth2Signature(),
diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go
index e943cb00..23ecc4c5 100644
--- a/core/validatorapi/validatorapi_test.go
+++ b/core/validatorapi/validatorapi_test.go
@@ -17,6 +17,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -814,7 +815,7 @@ func TestComponent_SubmitProposal(t *testing.T) {
 			signedBlockFunc: func(unsignedBlock *eth2spec.VersionedBeaconBlock, s [96]byte) *eth2api.VersionedSignedProposal {
 				return &eth2api.VersionedSignedProposal{
 					Version: eth2spec.DataVersionFulu,
-					Fulu: &eth2electra.SignedBlockContents{
+					Fulu: &eth2fulu.SignedBlockContents{
 						SignedBlock: &electra.SignedBeaconBlock{
 							Message:   unsignedBlock.Fulu,
 							Signature: eth2p0.BLSSignature(s),
@@ -827,7 +828,7 @@ func TestComponent_SubmitProposal(t *testing.T) {
 			awaitBlockFunc: func(unsignedBlock *eth2spec.VersionedBeaconBlock, signedBlock *eth2api.VersionedSignedProposal) *eth2api.VersionedProposal {
 				return &eth2api.VersionedProposal{
 					Version: signedBlock.Version,
-					Fulu: &eth2electra.BlockContents{
+					Fulu: &eth2fulu.BlockContents{
 						Block:     unsignedBlock.Fulu,
 						KZGProofs: []deneb.KZGProof{},
 						Blobs:     []deneb.Blob{},
diff --git a/testutil/random.go b/testutil/random.go
index 9d39c389..401e7b3c 100644
--- a/testutil/random.go
+++ b/testutil/random.go
@@ -20,6 +20,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -455,7 +456,7 @@ func RandomFuluCoreVersionedSignedProposal() core.VersionedSignedProposal {
 	return core.VersionedSignedProposal{
 		VersionedSignedProposal: eth2api.VersionedSignedProposal{
 			Version: eth2spec.DataVersionFulu,
-			Fulu: &eth2electra.SignedBlockContents{
+			Fulu: &eth2fulu.SignedBlockContents{
 				SignedBlock: &electra.SignedBeaconBlock{
 					Message:   RandomElectraBeaconBlock(),
 					Signature: RandomEth2Signature(),
@@ -556,7 +557,7 @@ func RandomElectraVersionedSignedProposal() *eth2api.VersionedSignedProposal {
 func RandomFuluVersionedSignedProposal() *eth2api.VersionedSignedProposal {
 	return &eth2api.VersionedSignedProposal{
 		Version: eth2spec.DataVersionFulu,
-		Fulu: &eth2electra.SignedBlockContents{
+		Fulu: &eth2fulu.SignedBlockContents{
 			SignedBlock: &electra.SignedBeaconBlock{
 				Message:   RandomElectraBeaconBlock(),
 				Signature: RandomEth2Signature(),
@@ -603,7 +604,7 @@ func RandomElectraVersionedProposal() *eth2api.VersionedProposal {
 func RandomFuluVersionedProposal() *eth2api.VersionedProposal {
 	return &eth2api.VersionedProposal{
 		Version: eth2spec.DataVersionFulu,
-		Fulu: &eth2electra.BlockContents{
+		Fulu: &eth2fulu.BlockContents{
 			Block:     RandomElectraBeaconBlock(),
 			KZGProofs: []deneb.KZGProof{},
 			Blobs:     []deneb.Blob{},
diff --git a/testutil/validatormock/propose.go b/testutil/validatormock/propose.go
index eb28e22f..42cc94cf 100644
--- a/testutil/validatormock/propose.go
+++ b/testutil/validatormock/propose.go
@@ -12,6 +12,7 @@ import (
 	eth2capella "github.com/attestantio/go-eth2-client/api/v1/capella"
 	eth2deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
 	eth2electra "github.com/attestantio/go-eth2-client/api/v1/electra"
+	eth2fulu "github.com/attestantio/go-eth2-client/api/v1/fulu"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/bellatrix"
@@ -216,7 +217,7 @@ func ProposeBlock(ctx context.Context, eth2Cl eth2wrap.Client, signFunc SignFunc
 			Blobs:     block.Electra.Blobs,
 		}
 	case eth2spec.DataVersionFulu:
-		signedBlock.Fulu = &eth2electra.SignedBlockContents{
+		signedBlock.Fulu = &eth2fulu.SignedBlockContents{
 			SignedBlock: &electra.SignedBeaconBlock{
 				Message:   block.Fulu.Block,
 				Signature: sig,