From 426497fe5c113455b23f5e1621f1139da8f931d4 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:43:00 +1000 Subject: [PATCH 01/11] feat(sentry): fetch Gloas-aware v2 debug fork choice The sentry now fetches GET /eth/v2/debug/fork_choice (ethereum/beacon-APIs#615), which returns one node per (block root, payload status) pair, and falls back to v1 for beacon nodes that do not serve it (retrying v2 hourly). Since Gloas, the v1 endpoint returns up to three indistinguishable nodes per block on some clients, and none of them report payload_status in extra_data, so ForkChoiceNodeV2's payload_status was never set. ForkChoiceNodeV2 gains the v2 fields: parent_payload_status and the Payload Timeliness Committee vote counts. ForkChoiceV2 gains the fork choice store's extra_data (e.g. the unrealized justified checkpoint), from both endpoints. All additive, so event names, Kafka topics and downstream routes are unchanged. Consumers converting to the go-eth2-client v1 shape still see the new fields in extra_data, and a node without extra data no longer panics that conversion. Bumps github.com/ethpandaops/go-eth2-client to the fork choice v2 branch (ethpandaops/go-eth2-client#60). Co-Authored-By: Claude Opus 5.5 --- go.mod | 6 +- go.sum | 12 +- pkg/proto/eth/v1/fork_choice.go | 153 ++++++++++- pkg/proto/eth/v1/fork_choice.pb.go | 99 ++++++- pkg/proto/eth/v1/fork_choice.proto | 28 ++ pkg/proto/eth/v1/fork_choice_test.go | 132 ++++++++++ pkg/proto/eth/v1/fork_choice_vtproto.pb.go | 243 ++++++++++++++++++ .../event/beacon/eth/v1/debug_fork_choice.go | 9 +- pkg/sentry/fork_choice.go | 55 +++- pkg/sentry/fork_choice_test.go | 33 +++ pkg/sentry/sentry.go | 5 + 11 files changed, 753 insertions(+), 22 deletions(-) create mode 100644 pkg/proto/eth/v1/fork_choice_test.go create mode 100644 pkg/sentry/fork_choice_test.go diff --git a/go.mod b/go.mod index c5afff6a..b87f4c9a 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.7 + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 @@ -88,7 +88,7 @@ require ( github.com/parquet-go/jsonlite v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pk910/hashtree-bindings v0.2.5 // indirect + github.com/pk910/hashtree-bindings v0.2.6 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/twpayne/go-geom v1.6.1 // indirect @@ -260,7 +260,7 @@ require ( github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/webrtc/v4 v4.1.2 // indirect - github.com/pk910/dynamic-ssz v1.3.3-0.20260812091520-ef568569f9c1 + github.com/pk910/dynamic-ssz v1.3.3-0.20260921152246-990798fa9718 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/common v0.70.1 // indirect diff --git a/go.sum b/go.sum index 550675d6..cb9a2c50 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.7 h1:EzZP50rrpVsjk2U8cOpN+kgNDTd/9IZNQXkh9t8G9AQ= -github.com/ethpandaops/go-eth2-client v0.1.7/go.mod h1:MXQukU/345puJmB2EikoaeQIFizk3zbekPxwYyxG/t8= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc h1:ZAMfGiRhUgNif4+lNf2e3AN+UNaMGJ5aeAbxF6T9Ytc= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -699,10 +699,10 @@ github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= -github.com/pk910/dynamic-ssz v1.3.3-0.20260812091520-ef568569f9c1 h1:s9bqZutzC2JSpKMOIjFEGqpaCAOPtX4BqLEPLbrv7Wg= -github.com/pk910/dynamic-ssz v1.3.3-0.20260812091520-ef568569f9c1/go.mod h1:5qeouU7Xd1RPblfOr5QBHQyZlNmQJ6WJK9xK+p/5Iwo= -github.com/pk910/hashtree-bindings v0.2.5 h1:zwONqRIgw40yGEOnGXBORvQVYaXtdZE3bS1ZJohc2/s= -github.com/pk910/hashtree-bindings v0.2.5/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= +github.com/pk910/dynamic-ssz v1.3.3-0.20260921152246-990798fa9718 h1:ctdxKDq6c360JEH5gkS6jupRpyRO9BYTTPEdE6RUDuQ= +github.com/pk910/dynamic-ssz v1.3.3-0.20260921152246-990798fa9718/go.mod h1:mnRbhjX+4YMyP4jYMncTUA3aDpMzBLqAbjcvfCyOyRI= +github.com/pk910/hashtree-bindings v0.2.6 h1:BjRQho04nWzGrXwBCLVTyQRNV30O59iPcvQRcidFt2o= +github.com/pk910/hashtree-bindings v0.2.6/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/pkg/proto/eth/v1/fork_choice.go b/pkg/proto/eth/v1/fork_choice.go index f9fa0922..26555526 100644 --- a/pkg/proto/eth/v1/fork_choice.go +++ b/pkg/proto/eth/v1/fork_choice.go @@ -68,6 +68,13 @@ func (f *ForkChoiceV2) AsGoEth2ClientV1ForkChoice() (*eth2v1.ForkChoice, error) nodes = append(nodes, node) } + var extraData map[string]any + if f.GetExtraData() != "" { + if err := json.Unmarshal([]byte(f.GetExtraData()), &extraData); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal extra_data") + } + } + return ð2v1.ForkChoice{ JustifiedCheckpoint: phase0.Checkpoint{ Epoch: phase0.Epoch(f.JustifiedCheckpoint.Epoch.GetValue()), @@ -78,6 +85,7 @@ func (f *ForkChoiceV2) AsGoEth2ClientV1ForkChoice() (*eth2v1.ForkChoice, error) Root: finalizedRoot, }, ForkChoiceNodes: nodes, + ExtraData: extraData, }, nil } @@ -145,12 +153,33 @@ func (f *ForkChoiceNodeV2) AsGoEth2ClientV1ForkChoiceNode() (*eth2v1.ForkChoiceN return nil, errors.Wrap(err, "failed to marshal extra_data") } - // Round-trip the EIP-7732 payload_status into extra_data so downstream - // consumers that only inspect extra_data still see it. + // A node without extra data is encoded as JSON null, which decodes to a + // nil map. + if extraData == nil { + extraData = make(map[string]any) + } + + // Round-trip the EIP-7732 payload_status and the other v2 fork choice + // fields into extra_data so downstream consumers that only inspect + // extra_data still see them. if ps := f.GetPayloadStatus(); ps != nil { extraData["payload_status"] = ps.GetValue() } + if ps := f.GetParentPayloadStatus(); ps != nil { + extraData["parent_payload_status"] = ps.GetValue() + } + + for key, value := range map[string]*wrapperspb.UInt64Value{ + "payload_attester_count": f.GetPayloadAttesterCount(), + "payload_availability_yes_count": f.GetPayloadAvailabilityYesCount(), + "payload_data_availability_yes_count": f.GetPayloadDataAvailabilityYesCount(), + } { + if _, exists := extraData[key]; !exists && value != nil { + extraData[key] = value.GetValue() + } + } + validity, err := eth2v1.ForkChoiceNodeValidityFromString(f.Validity) if err != nil { return nil, errors.Wrap(err, "failed to convert validity") @@ -195,6 +224,11 @@ func NewForkChoiceFromGoEth2ClientV1(f *eth2v1.ForkChoice) (*ForkChoice, error) } func NewForkChoiceV2FromGoEth2ClientV1(f *eth2v1.ForkChoice) (*ForkChoiceV2, error) { + extraData, err := marshalExtraData(f.ExtraData) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal extra_data") + } + nodes := []*ForkChoiceNodeV2{} for _, node := range f.ForkChoiceNodes { @@ -220,6 +254,7 @@ func NewForkChoiceV2FromGoEth2ClientV1(f *eth2v1.ForkChoice) (*ForkChoiceV2, err }, }, ForkChoiceNodes: nodes, + ExtraData: extraData, }, nil } @@ -304,3 +339,117 @@ func payloadStatusFromExtraData(extraData map[string]any) (uint32, bool) { return 0, false } + +// NewForkChoiceV2FromGoEth2ClientV2 converts a GET /eth/v2/debug/fork_choice +// response, with one node per (block root, payload status) pair, keeping the +// Gloas payload fields the v1 endpoint lacks. +func NewForkChoiceV2FromGoEth2ClientV2(f *eth2v1.ForkChoiceV2) (*ForkChoiceV2, error) { + extraData, err := marshalExtraData(f.ExtraData) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal extra_data") + } + + nodes := make([]*ForkChoiceNodeV2, 0, len(f.ForkChoiceNodes)) + + for _, node := range f.ForkChoiceNodes { + n, err := NewForkChoiceNodeV2FromGoEth2ClientV2(node) + if err != nil { + return nil, errors.Wrap(err, "failed to convert node") + } + + nodes = append(nodes, n) + } + + return &ForkChoiceV2{ + FinalizedCheckpoint: &CheckpointV2{ + Root: RootAsString(f.FinalizedCheckpoint.Root), + Epoch: &wrapperspb.UInt64Value{Value: uint64(f.FinalizedCheckpoint.Epoch)}, + }, + JustifiedCheckpoint: &CheckpointV2{ + Root: RootAsString(f.JustifiedCheckpoint.Root), + Epoch: &wrapperspb.UInt64Value{Value: uint64(f.JustifiedCheckpoint.Epoch)}, + }, + ForkChoiceNodes: nodes, + ExtraData: extraData, + }, nil +} + +// NewForkChoiceNodeV2FromGoEth2ClientV2 converts a v2 debug fork choice node. +// Values the client does not provide are left unset. +func NewForkChoiceNodeV2FromGoEth2ClientV2(node *eth2v1.ForkChoiceNodeV2) (*ForkChoiceNodeV2, error) { + extraData, err := json.Marshal(node.ExtraData) + if err != nil { + return nil, err + } + + out := &ForkChoiceNodeV2{ + Slot: &wrapperspb.UInt64Value{Value: uint64(node.Slot)}, + BlockRoot: RootAsString(node.BlockRoot), + ParentRoot: RootAsString(node.ParentRoot), + Weight: &wrapperspb.UInt64Value{Value: node.Weight}, + Validity: node.Validity.String(), + ExecutionBlockHash: RootAsString(phase0.Root(node.ExecutionBlockHash)), + ExtraData: string(extraData), + PayloadAttesterCount: optionalUint64Value(node.PayloadAttesterCount), + PayloadAvailabilityYesCount: optionalUint64Value(node.PayloadAvailabilityYesCount), + PayloadDataAvailabilityYesCount: optionalUint64Value(node.PayloadDataAvailabilityYesCount), + } + + if node.JustifiedEpoch != nil { + out.JustifiedEpoch = &wrapperspb.UInt64Value{Value: uint64(*node.JustifiedEpoch)} + } + + if node.FinalizedEpoch != nil { + out.FinalizedEpoch = &wrapperspb.UInt64Value{Value: uint64(*node.FinalizedEpoch)} + } + + if v, ok := payloadStatusValue(node.PayloadStatus); ok { + out.PayloadStatus = &wrapperspb.UInt32Value{Value: v} + } + + if node.ParentPayloadStatus != nil { + if v, ok := payloadStatusValue(*node.ParentPayloadStatus); ok { + out.ParentPayloadStatus = &wrapperspb.UInt32Value{Value: v} + } + } + + return out, nil +} + +// payloadStatusValue maps a beacon API payload status onto the spec's +// PayloadStatus enum (PAYLOAD_STATUS_EMPTY = 0, FULL = 1, PENDING = 2). +func payloadStatusValue(status eth2v1.ForkChoicePayloadStatus) (uint32, bool) { + switch status { + case eth2v1.ForkChoicePayloadStatusEmpty: + return 0, true + case eth2v1.ForkChoicePayloadStatusFull: + return 1, true + case eth2v1.ForkChoicePayloadStatusPending: + return 2, true + default: + return 0, false + } +} + +func optionalUint64Value(value *uint64) *wrapperspb.UInt64Value { + if value == nil { + return nil + } + + return &wrapperspb.UInt64Value{Value: *value} +} + +// marshalExtraData encodes a fork choice store's extra data, leaving it empty +// when the client provides none. +func marshalExtraData(extraData map[string]any) (string, error) { + if len(extraData) == 0 { + return "", nil + } + + encoded, err := json.Marshal(extraData) + if err != nil { + return "", err + } + + return string(encoded), nil +} diff --git a/pkg/proto/eth/v1/fork_choice.pb.go b/pkg/proto/eth/v1/fork_choice.pb.go index efdcf069..59c79694 100644 --- a/pkg/proto/eth/v1/fork_choice.pb.go +++ b/pkg/proto/eth/v1/fork_choice.pb.go @@ -96,6 +96,11 @@ type ForkChoiceV2 struct { JustifiedCheckpoint *CheckpointV2 `protobuf:"bytes,1,opt,name=justified_checkpoint,proto3" json:"justified_checkpoint,omitempty"` FinalizedCheckpoint *CheckpointV2 `protobuf:"bytes,2,opt,name=finalized_checkpoint,proto3" json:"finalized_checkpoint,omitempty"` ForkChoiceNodes []*ForkChoiceNodeV2 `protobuf:"bytes,3,rep,name=fork_choice_nodes,proto3" json:"fork_choice_nodes,omitempty"` + // Client-specific values of the fork-choice store (e.g. the unrealized + // justified checkpoint, proposer boost root), as a JSON object. From the + // response's top-level extra_data on both the v1 and v2 debug fork choice + // endpoints. + ExtraData string `protobuf:"bytes,4,opt,name=extra_data,proto3" json:"extra_data,omitempty"` } func (x *ForkChoiceV2) Reset() { @@ -151,6 +156,13 @@ func (x *ForkChoiceV2) GetForkChoiceNodes() []*ForkChoiceNodeV2 { return nil } +func (x *ForkChoiceV2) GetExtraData() string { + if x != nil { + return x.ExtraData + } + return "" +} + type ForkChoiceNode struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -282,6 +294,17 @@ type ForkChoiceNodeV2 struct { // Pre-Gloas nodes leave this unset. consensus-specs PR #5094 narrowed the // scope to per-node tracking (no Store-level payload_states mirror). PayloadStatus *wrapperspb.UInt32Value `protobuf:"bytes,10,opt,name=payload_status,proto3" json:"payload_status,omitempty"` + // EIP-7732 ePBS: payload status of the parent fork-choice node, using the + // payload_status enum (0 = EMPTY, 1 = FULL, 2 = PENDING). For a pending + // node, whether its block was built on the parent block's empty or full + // payload; for empty and full nodes, pending. + ParentPayloadStatus *wrapperspb.UInt32Value `protobuf:"bytes,11,opt,name=parent_payload_status,proto3" json:"parent_payload_status,omitempty"` + // Payload Timeliness Committee votes for the block's payload: positions + // with a recorded vote, voting the payload timely, and voting its blob + // data available. + PayloadAttesterCount *wrapperspb.UInt64Value `protobuf:"bytes,12,opt,name=payload_attester_count,proto3" json:"payload_attester_count,omitempty"` + PayloadAvailabilityYesCount *wrapperspb.UInt64Value `protobuf:"bytes,13,opt,name=payload_availability_yes_count,proto3" json:"payload_availability_yes_count,omitempty"` + PayloadDataAvailabilityYesCount *wrapperspb.UInt64Value `protobuf:"bytes,14,opt,name=payload_data_availability_yes_count,proto3" json:"payload_data_availability_yes_count,omitempty"` } func (x *ForkChoiceNodeV2) Reset() { @@ -386,6 +409,34 @@ func (x *ForkChoiceNodeV2) GetPayloadStatus() *wrapperspb.UInt32Value { return nil } +func (x *ForkChoiceNodeV2) GetParentPayloadStatus() *wrapperspb.UInt32Value { + if x != nil { + return x.ParentPayloadStatus + } + return nil +} + +func (x *ForkChoiceNodeV2) GetPayloadAttesterCount() *wrapperspb.UInt64Value { + if x != nil { + return x.PayloadAttesterCount + } + return nil +} + +func (x *ForkChoiceNodeV2) GetPayloadAvailabilityYesCount() *wrapperspb.UInt64Value { + if x != nil { + return x.PayloadAvailabilityYesCount + } + return nil +} + +func (x *ForkChoiceNodeV2) GetPayloadDataAvailabilityYesCount() *wrapperspb.UInt64Value { + if x != nil { + return x.PayloadDataAvailabilityYesCount + } + return nil +} + var File_pkg_proto_eth_v1_fork_choice_proto protoreflect.FileDescriptor var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ @@ -413,7 +464,7 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x6f, 0x64, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x78, 0x61, 0x74, 0x75, 0x2e, 0x65, 0x74, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x68, - 0x6f, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x22, 0xf9, 0x01, 0x0a, 0x0c, 0x46, + 0x6f, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x22, 0x99, 0x02, 0x0a, 0x0c, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x56, 0x32, 0x12, 0x4d, 0x0a, 0x14, 0x6a, 0x75, 0x73, 0x74, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x61, 0x74, 0x75, @@ -429,7 +480,9 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x78, 0x61, 0x74, 0x75, 0x2e, 0x65, 0x74, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x56, 0x32, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x68, 0x6f, 0x69, 0x63, 0x65, - 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x22, 0xbf, 0x02, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x6b, 0x43, + 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x72, + 0x61, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x22, 0xbf, 0x02, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x6c, 0x6f, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x72, 0x6f, 0x6f, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, @@ -449,7 +502,7 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x01, 0x28, 0x09, 0x52, 0x14, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1d, 0x0a, 0x09, 0x45, 0x78, 0x74, 0x72, 0x61, 0x44, 0x61, 0x74, 0x61, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, - 0x74, 0x72, 0x61, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x22, 0xff, 0x03, 0x0a, 0x10, 0x46, 0x6f, 0x72, + 0x74, 0x72, 0x61, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x22, 0xff, 0x06, 0x0a, 0x10, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x56, 0x32, 0x12, 0x30, 0x0a, 0x04, 0x73, 0x6c, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, @@ -481,7 +534,31 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x70, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, + 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x52, 0x0a, 0x15, 0x70, 0x61, + 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, 0x6e, 0x74, + 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x15, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, + 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x54, + 0x0a, 0x16, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, + 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x55, 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x16, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x65, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x64, 0x0a, 0x1e, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, + 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x5f, 0x79, 0x65, 0x73, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, + 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x1e, 0x70, 0x61, 0x79, 0x6c, + 0x6f, 0x61, 0x64, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x5f, 0x79, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x6e, 0x0a, 0x23, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x5f, 0x79, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, 0x6e, 0x74, 0x36, 0x34, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x23, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x64, + 0x61, 0x74, 0x61, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x5f, 0x79, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x74, 0x68, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x6f, 0x70, 0x73, 0x2f, 0x78, 0x61, 0x74, 0x75, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x74, 0x68, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, @@ -523,11 +600,15 @@ var file_pkg_proto_eth_v1_fork_choice_proto_depIdxs = []int32{ 6, // 8: xatu.eth.v1.ForkChoiceNodeV2.finalized_epoch:type_name -> google.protobuf.UInt64Value 6, // 9: xatu.eth.v1.ForkChoiceNodeV2.weight:type_name -> google.protobuf.UInt64Value 7, // 10: xatu.eth.v1.ForkChoiceNodeV2.payload_status:type_name -> google.protobuf.UInt32Value - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 7, // 11: xatu.eth.v1.ForkChoiceNodeV2.parent_payload_status:type_name -> google.protobuf.UInt32Value + 6, // 12: xatu.eth.v1.ForkChoiceNodeV2.payload_attester_count:type_name -> google.protobuf.UInt64Value + 6, // 13: xatu.eth.v1.ForkChoiceNodeV2.payload_availability_yes_count:type_name -> google.protobuf.UInt64Value + 6, // 14: xatu.eth.v1.ForkChoiceNodeV2.payload_data_availability_yes_count:type_name -> google.protobuf.UInt64Value + 15, // [15:15] is the sub-list for method output_type + 15, // [15:15] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_pkg_proto_eth_v1_fork_choice_proto_init() } diff --git a/pkg/proto/eth/v1/fork_choice.proto b/pkg/proto/eth/v1/fork_choice.proto index 9de8f1dc..d0439930 100644 --- a/pkg/proto/eth/v1/fork_choice.proto +++ b/pkg/proto/eth/v1/fork_choice.proto @@ -27,6 +27,12 @@ message ForkChoiceV2 { repeated ForkChoiceNodeV2 fork_choice_nodes = 3 [ json_name = "fork_choice_nodes" ]; + + // Client-specific values of the fork-choice store (e.g. the unrealized + // justified checkpoint, proposer boost root), as a JSON object. From the + // response's top-level extra_data on both the v1 and v2 debug fork choice + // endpoints. + string extra_data = 4 [ json_name = "extra_data" ]; } message ForkChoiceNode { @@ -76,4 +82,26 @@ message ForkChoiceNodeV2 { // Pre-Gloas nodes leave this unset. consensus-specs PR #5094 narrowed the // scope to per-node tracking (no Store-level payload_states mirror). google.protobuf.UInt32Value payload_status = 10 [ json_name = "payload_status" ]; + + // The fields below come from GET /eth/v2/debug/fork_choice + // (ethereum/beacon-APIs#615), which returns one node per (block_root, + // payload_status) pair. Unset for nodes fetched from the v1 endpoint, and + // for values a client does not provide. + + // EIP-7732 ePBS: payload status of the parent fork-choice node, using the + // payload_status enum (0 = EMPTY, 1 = FULL, 2 = PENDING). For a pending + // node, whether its block was built on the parent block's empty or full + // payload; for empty and full nodes, pending. + google.protobuf.UInt32Value parent_payload_status = 11 + [ json_name = "parent_payload_status" ]; + + // Payload Timeliness Committee votes for the block's payload: positions + // with a recorded vote, voting the payload timely, and voting its blob + // data available. + google.protobuf.UInt64Value payload_attester_count = 12 + [ json_name = "payload_attester_count" ]; + google.protobuf.UInt64Value payload_availability_yes_count = 13 + [ json_name = "payload_availability_yes_count" ]; + google.protobuf.UInt64Value payload_data_availability_yes_count = 14 + [ json_name = "payload_data_availability_yes_count" ]; } diff --git a/pkg/proto/eth/v1/fork_choice_test.go b/pkg/proto/eth/v1/fork_choice_test.go new file mode 100644 index 00000000..9234ae92 --- /dev/null +++ b/pkg/proto/eth/v1/fork_choice_test.go @@ -0,0 +1,132 @@ +package v1 + +import ( + "encoding/json" + "testing" + + eth2v1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func payloadStatusPtr(s eth2v1.ForkChoicePayloadStatus) *eth2v1.ForkChoicePayloadStatus { + return &s +} + +func uint64Ptr(v uint64) *uint64 { + return &v +} + +func epochPtr(v phase0.Epoch) *phase0.Epoch { + return &v +} + +func testForkChoiceV2() *eth2v1.ForkChoiceV2 { + return ð2v1.ForkChoiceV2{ + JustifiedCheckpoint: phase0.Checkpoint{Epoch: 3, Root: phase0.Root{0xa}}, + FinalizedCheckpoint: phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x9}}, + ForkChoiceNodes: []*eth2v1.ForkChoiceNodeV2{ + { + // a client that implements the spec shape + Slot: 29, + BlockRoot: phase0.Root{0xb}, + PayloadStatus: eth2v1.ForkChoicePayloadStatusPending, + ParentRoot: phase0.Root{0xa}, + ParentPayloadStatus: payloadStatusPtr(eth2v1.ForkChoicePayloadStatusFull), + JustifiedEpoch: epochPtr(3), + FinalizedEpoch: epochPtr(2), + Weight: 128, + Validity: eth2v1.ForkChoiceNodeValidityValid, + ExecutionBlockHash: phase0.Hash32{0x1}, + PayloadAttesterCount: uint64Ptr(512), + PayloadAvailabilityYesCount: uint64Ptr(498), + PayloadDataAvailabilityYesCount: uint64Ptr(497), + ExtraData: map[string]any{"state_root": "0xc0"}, + }, + { + // a client that leaves the optional fields out + Slot: 29, + BlockRoot: phase0.Root{0xb}, + PayloadStatus: eth2v1.ForkChoicePayloadStatusEmpty, + ParentRoot: phase0.Root{0xa}, + Weight: 0, + Validity: eth2v1.ForkChoiceNodeValidityValid, + ExecutionBlockHash: phase0.Hash32{0x1}, + }, + }, + ExtraData: map[string]any{"proposer_boost_root": "0xb0"}, + } +} + +func TestNewForkChoiceV2FromGoEth2ClientV2(t *testing.T) { + fc, err := NewForkChoiceV2FromGoEth2ClientV2(testForkChoiceV2()) + require.NoError(t, err) + + assert.Equal(t, uint64(3), fc.GetJustifiedCheckpoint().GetEpoch().GetValue()) + assert.Equal(t, uint64(2), fc.GetFinalizedCheckpoint().GetEpoch().GetValue()) + assert.JSONEq(t, `{"proposer_boost_root":"0xb0"}`, fc.GetExtraData()) + require.Len(t, fc.GetForkChoiceNodes(), 2) + + pending := fc.GetForkChoiceNodes()[0] + assert.Equal(t, uint32(2), pending.GetPayloadStatus().GetValue(), "PAYLOAD_STATUS_PENDING") + assert.Equal(t, uint32(1), pending.GetParentPayloadStatus().GetValue(), "PAYLOAD_STATUS_FULL") + assert.Equal(t, uint64(3), pending.GetJustifiedEpoch().GetValue()) + assert.Equal(t, uint64(2), pending.GetFinalizedEpoch().GetValue()) + assert.Equal(t, uint64(512), pending.GetPayloadAttesterCount().GetValue()) + assert.Equal(t, uint64(498), pending.GetPayloadAvailabilityYesCount().GetValue()) + assert.Equal(t, uint64(497), pending.GetPayloadDataAvailabilityYesCount().GetValue()) + assert.Equal(t, RootAsString(phase0.Root{0x1}), pending.GetExecutionBlockHash()) + assert.JSONEq(t, `{"state_root":"0xc0"}`, pending.GetExtraData()) + + empty := fc.GetForkChoiceNodes()[1] + assert.NotNil(t, empty.GetPayloadStatus()) + assert.Equal(t, uint32(0), empty.GetPayloadStatus().GetValue(), "PAYLOAD_STATUS_EMPTY") + assert.Nil(t, empty.GetParentPayloadStatus()) + assert.Nil(t, empty.GetJustifiedEpoch()) + assert.Nil(t, empty.GetFinalizedEpoch()) + assert.Nil(t, empty.GetPayloadAttesterCount()) +} + +// Consumers that convert to the go-eth2-client v1 shape still see the v2 +// fields, in extra_data. +func TestForkChoiceV2RoundTripsV2FieldsIntoExtraData(t *testing.T) { + fc, err := NewForkChoiceV2FromGoEth2ClientV2(testForkChoiceV2()) + require.NoError(t, err) + + v1, err := fc.AsGoEth2ClientV1ForkChoice() + require.NoError(t, err) + + assert.Equal(t, map[string]any{"proposer_boost_root": "0xb0"}, v1.ExtraData) + require.Len(t, v1.ForkChoiceNodes, 2) + + extra, err := json.Marshal(v1.ForkChoiceNodes[0].ExtraData) + require.NoError(t, err) + assert.JSONEq(t, `{ + "state_root": "0xc0", + "payload_status": 2, + "parent_payload_status": 1, + "payload_attester_count": 512, + "payload_availability_yes_count": 498, + "payload_data_availability_yes_count": 497 + }`, string(extra)) + + assert.Equal(t, map[string]any{"payload_status": uint32(0)}, v1.ForkChoiceNodes[1].ExtraData) +} + +func TestNewForkChoiceV2FromGoEth2ClientV1KeepsStoreExtraData(t *testing.T) { + fc, err := NewForkChoiceV2FromGoEth2ClientV1(ð2v1.ForkChoice{ + JustifiedCheckpoint: phase0.Checkpoint{Epoch: 3}, + FinalizedCheckpoint: phase0.Checkpoint{Epoch: 2}, + ForkChoiceNodes: []*eth2v1.ForkChoiceNode{}, + ExtraData: map[string]any{ + "unrealized_justified_checkpoint": map[string]any{"epoch": "4", "root": "0xd0"}, + }, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"unrealized_justified_checkpoint":{"epoch":"4","root":"0xd0"}}`, fc.GetExtraData()) + + fc, err = NewForkChoiceV2FromGoEth2ClientV1(ð2v1.ForkChoice{ForkChoiceNodes: []*eth2v1.ForkChoiceNode{}}) + require.NoError(t, err) + assert.Empty(t, fc.GetExtraData()) +} diff --git a/pkg/proto/eth/v1/fork_choice_vtproto.pb.go b/pkg/proto/eth/v1/fork_choice_vtproto.pb.go index 821cf317..7361b321 100644 --- a/pkg/proto/eth/v1/fork_choice_vtproto.pb.go +++ b/pkg/proto/eth/v1/fork_choice_vtproto.pb.go @@ -116,6 +116,13 @@ func (m *ForkChoiceV2) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.ExtraData) > 0 { + i -= len(m.ExtraData) + copy(dAtA[i:], m.ExtraData) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.ExtraData))) + i-- + dAtA[i] = 0x22 + } if len(m.ForkChoiceNodes) > 0 { for iNdEx := len(m.ForkChoiceNodes) - 1; iNdEx >= 0; iNdEx-- { size, err := m.ForkChoiceNodes[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) @@ -269,6 +276,46 @@ func (m *ForkChoiceNodeV2) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.PayloadDataAvailabilityYesCount != nil { + size, err := (*wrapperspb.UInt64Value)(m.PayloadDataAvailabilityYesCount).MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x72 + } + if m.PayloadAvailabilityYesCount != nil { + size, err := (*wrapperspb.UInt64Value)(m.PayloadAvailabilityYesCount).MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x6a + } + if m.PayloadAttesterCount != nil { + size, err := (*wrapperspb.UInt64Value)(m.PayloadAttesterCount).MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x62 + } + if m.ParentPayloadStatus != nil { + size, err := (*wrapperspb.UInt32Value)(m.ParentPayloadStatus).MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x5a + } if m.PayloadStatus != nil { size, err := (*wrapperspb.UInt32Value)(m.PayloadStatus).MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -498,6 +545,10 @@ func (m *ForkChoiceV2) SizeVT() (n int) { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } } + l = len(m.ExtraData) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -590,6 +641,22 @@ func (m *ForkChoiceNodeV2) SizeVT() (n int) { l = (*wrapperspb.UInt32Value)(m.PayloadStatus).SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + if m.ParentPayloadStatus != nil { + l = (*wrapperspb.UInt32Value)(m.ParentPayloadStatus).SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.PayloadAttesterCount != nil { + l = (*wrapperspb.UInt64Value)(m.PayloadAttesterCount).SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.PayloadAvailabilityYesCount != nil { + l = (*wrapperspb.UInt64Value)(m.PayloadAvailabilityYesCount).SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.PayloadDataAvailabilityYesCount != nil { + l = (*wrapperspb.UInt64Value)(m.PayloadDataAvailabilityYesCount).SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -900,6 +967,38 @@ func (m *ForkChoiceV2) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ExtraData", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ExtraData = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -1578,6 +1677,150 @@ func (m *ForkChoiceNodeV2) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 11: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ParentPayloadStatus", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.ParentPayloadStatus == nil { + m.ParentPayloadStatus = &wrapperspb1.UInt32Value{} + } + if err := (*wrapperspb.UInt32Value)(m.ParentPayloadStatus).UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 12: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field PayloadAttesterCount", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.PayloadAttesterCount == nil { + m.PayloadAttesterCount = &wrapperspb1.UInt64Value{} + } + if err := (*wrapperspb.UInt64Value)(m.PayloadAttesterCount).UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 13: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field PayloadAvailabilityYesCount", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.PayloadAvailabilityYesCount == nil { + m.PayloadAvailabilityYesCount = &wrapperspb1.UInt64Value{} + } + if err := (*wrapperspb.UInt64Value)(m.PayloadAvailabilityYesCount).UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 14: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field PayloadDataAvailabilityYesCount", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.PayloadDataAvailabilityYesCount == nil { + m.PayloadDataAvailabilityYesCount = &wrapperspb1.UInt64Value{} + } + if err := (*wrapperspb.UInt64Value)(m.PayloadDataAvailabilityYesCount).UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/pkg/sentry/event/beacon/eth/v1/debug_fork_choice.go b/pkg/sentry/event/beacon/eth/v1/debug_fork_choice.go index f3c07c2b..5056c6cc 100644 --- a/pkg/sentry/event/beacon/eth/v1/debug_fork_choice.go +++ b/pkg/sentry/event/beacon/eth/v1/debug_fork_choice.go @@ -27,7 +27,10 @@ type ForkChoice struct { } type ForkChoiceSnapshot struct { - Event *eth2v1.ForkChoice + Event *eth2v1.ForkChoice + // EventV2 is set instead of Event when the beacon node serves the + // Gloas-aware GET /eth/v2/debug/fork_choice. + EventV2 *eth2v1.ForkChoiceV2 RequestSlot phase0.Slot RequestEpoch phase0.Epoch RequestAt time.Time @@ -82,6 +85,10 @@ func (f *ForkChoice) ShouldIgnore(ctx context.Context) (bool, error) { } func (f *ForkChoice) GetData() (*xatuethv1.ForkChoiceV2, error) { + if f.snapshot.EventV2 != nil { + return xatuethv1.NewForkChoiceV2FromGoEth2ClientV2(f.snapshot.EventV2) + } + return xatuethv1.NewForkChoiceV2FromGoEth2ClientV1(f.snapshot.Event) } diff --git a/pkg/sentry/fork_choice.go b/pkg/sentry/fork_choice.go index ca15d279..2b37fec3 100644 --- a/pkg/sentry/fork_choice.go +++ b/pkg/sentry/fork_choice.go @@ -2,9 +2,13 @@ package sentry import ( "context" + "errors" + "net/http" "time" "github.com/ethpandaops/ethwallclock" + eth2client "github.com/ethpandaops/go-eth2-client" + "github.com/ethpandaops/go-eth2-client/api" eth2v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/go-co-op/gocron/v2" @@ -154,7 +158,7 @@ func (s *Sentry) fetchDebugForkChoice(ctx context.Context) (*v1.ForkChoice, erro RequestEpoch: phase0.Epoch(epoch.Number()), } - forkChoice, err := s.beacon.Node().FetchForkChoice(ctx) + forkChoice, forkChoiceV2, err := s.fetchForkChoice(ctx) if err != nil { return nil, err } @@ -166,6 +170,7 @@ func (s *Sentry) fetchDebugForkChoice(ctx context.Context) (*v1.ForkChoice, erro snapshot.RequestDuration = time.Since(startedAt) snapshot.Event = forkChoice + snapshot.EventV2 = forkChoiceV2 fc := v1.NewForkChoice(s.log, snapshot, s.beacon, meta) @@ -199,3 +204,51 @@ func (s *Sentry) fetchDecoratedDebugForkChoice(ctx context.Context) error { return s.handleNewDecoratedEvent(ctx, decoratedEvent) } + +// forkChoiceV2RetryInterval is how long to use the v1 fork choice endpoint +// before checking whether a beacon node has started supporting v2. +const forkChoiceV2RetryInterval = time.Hour + +// fetchForkChoice fetches the beacon node's fork choice, preferring the +// Gloas-aware GET /eth/v2/debug/fork_choice (one node per block root and +// payload status) and falling back to v1 for nodes that do not support it. +// Exactly one of the returned fork choices is set. +func (s *Sentry) fetchForkChoice(ctx context.Context) (*eth2v1.ForkChoice, *eth2v1.ForkChoiceV2, error) { + if provider, isProvider := s.beacon.Node().Service().(eth2client.ForkChoiceV2Provider); isProvider && + time.Now().UnixNano() >= s.forkChoiceV2RetryAt.Load() { + rsp, err := provider.ForkChoiceV2(ctx, &api.ForkChoiceOpts{}) + if err == nil { + return nil, rsp.Data, nil + } + + if isUnsupportedEndpoint(err) { + s.log.WithError(err).Debug("Beacon node does not support the v2 fork choice endpoint, falling back to v1") + s.forkChoiceV2RetryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) + } else { + s.log.WithError(err).Warn("Failed to fetch v2 fork choice, falling back to v1") + } + } + + forkChoice, err := s.beacon.Node().FetchForkChoice(ctx) + if err != nil { + return nil, nil, err + } + + return forkChoice, nil, nil +} + +// isUnsupportedEndpoint reports whether err is a beacon node rejecting an +// endpoint it does not implement. +func isUnsupportedEndpoint(err error) bool { + var apiErr *api.Error + if !errors.As(err, &apiErr) { + return false + } + + switch apiErr.StatusCode { + case http.StatusBadRequest, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented: + return true + default: + return false + } +} diff --git a/pkg/sentry/fork_choice_test.go b/pkg/sentry/fork_choice_test.go new file mode 100644 index 00000000..3dfc6306 --- /dev/null +++ b/pkg/sentry/fork_choice_test.go @@ -0,0 +1,33 @@ +package sentry + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/ethpandaops/go-eth2-client/api" + "github.com/stretchr/testify/assert" +) + +func TestIsUnsupportedEndpoint(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "lighthouse rejects the version", err: &api.Error{StatusCode: http.StatusBadRequest}, want: true}, + {name: "nimbus has no route", err: &api.Error{StatusCode: http.StatusNotFound}, want: true}, + {name: "method not allowed", err: &api.Error{StatusCode: http.StatusMethodNotAllowed}, want: true}, + {name: "not implemented", err: &api.Error{StatusCode: http.StatusNotImplemented}, want: true}, + {name: "wrapped", err: fmt.Errorf("fetch: %w", &api.Error{StatusCode: http.StatusNotFound}), want: true}, + {name: "server error", err: &api.Error{StatusCode: http.StatusInternalServerError}, want: false}, + {name: "not an api error", err: errors.New("connection refused"), want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, isUnsupportedEndpoint(test.err)) + }) + } +} diff --git a/pkg/sentry/sentry.go b/pkg/sentry/sentry.go index de249d74..09c3f3b7 100644 --- a/pkg/sentry/sentry.go +++ b/pkg/sentry/sentry.go @@ -13,6 +13,7 @@ import ( "os/signal" "runtime" "sync" + "sync/atomic" "syscall" "time" @@ -71,6 +72,10 @@ type Sentry struct { latestForkChoice *v1.ForkChoice latestForkChoiceMu sync.RWMutex + // forkChoiceV2RetryAt is when to next try the v2 fork choice endpoint, in + // unix nanoseconds, after the beacon node reported it as unsupported. + forkChoiceV2RetryAt atomic.Int64 + preset *Preset shutdownFuncs []func(context.Context) error From 3e99cc45c8df787edb7074903e145379f7ad864a Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:45:30 +1000 Subject: [PATCH 02/11] chore: bump go-eth2-client to the payload status enum fix Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b87f4c9a..1f6f494e 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index cb9a2c50..99d429f4 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc h1:ZAMfGiRhUgNif4+lNf2e3AN+UNaMGJ5aeAbxF6T9Ytc= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007005844-af4b8c6b22bc/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a h1:QhlQyB14ZfLTKyb8O7eCUJhuH3uhImBD/56rwlB/YSw= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From d04a128e7ae96f27e3dde39a90e45cae1708b095 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:52:58 +1000 Subject: [PATCH 03/11] chore: bump go-eth2-client for lenient fork choice decoding Picks up decoding unrecognised validities (Lighthouse's not_yet_revealed for unrevealed Gloas payloads) as unknown instead of failing the whole fork choice. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1f6f494e..dfde8590 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849 github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index 99d429f4..31d09b73 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a h1:QhlQyB14ZfLTKyb8O7eCUJhuH3uhImBD/56rwlB/YSw= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007024453-140ad87c112a/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849 h1:X5LTfgBV5IWn58ZXWNcT8OHy58wP19tTEBc+BKHE3Rg= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From 4d5840c59b55e1098e2dd668d38799772180af89 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:59:38 +1000 Subject: [PATCH 04/11] chore: bump go-eth2-client to keep unrecognised validities Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dfde8590..f1a3a07d 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849 + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index 31d09b73..d8fd207c 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849 h1:X5LTfgBV5IWn58ZXWNcT8OHy58wP19tTEBc+BKHE3Rg= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025228-b572018f0849/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a h1:gWHdrVlJLdgELvSPYzST1Cybfx+Mtc8AChI0EpEWOWE= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From fec1005f60f1876bde08b6c8f78acd7849120866 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:09:01 +1000 Subject: [PATCH 05/11] chore: bump go-eth2-client to 05ef7718 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f1a3a07d..33dbdca9 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index d8fd207c..a7aabbd9 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a h1:gWHdrVlJLdgELvSPYzST1Cybfx+Mtc8AChI0EpEWOWE= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007025853-9bd1e73c366a/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe h1:4855YGv4XKJFqp+uQ7pDUZNs7GbpbdIRGwx7szcNDSg= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From 7123274afb324d2cdba9893ca77dcfcdd2cf6950 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:12:46 +1000 Subject: [PATCH 06/11] chore: bump go-eth2-client to 2c54670f Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 33dbdca9..2e30d580 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index a7aabbd9..c75e968b 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe h1:4855YGv4XKJFqp+uQ7pDUZNs7GbpbdIRGwx7szcNDSg= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007030808-05ef77187ffe/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d h1:AksD3gmKVSlHOsnpkoi6dEvvjz+OCV4orolMa9FtmAE= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From ede1de64ffae7d31552c8909354958554787e0a6 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:19:03 +1000 Subject: [PATCH 07/11] chore: bump go-eth2-client to 0b35f098 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2e30d580..1d40a16f 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d + github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55 github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index c75e968b..67ea708f 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d h1:AksD3gmKVSlHOsnpkoi6dEvvjz+OCV4orolMa9FtmAE= -github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031214-2c54670fb67d/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55 h1:W1Tc0Vd2JTQheYcKhTQJUzjJgdSqq9GTd1aTfj2f8jE= +github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From ec6105b69059464f1bf22c6da6997cf90e9059d2 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:06:57 +1000 Subject: [PATCH 08/11] feat(sentry): follow the merged fork choice v2 spec Bump go-eth2-client to ethpandaops/go-eth2-client#60 as aligned with ethereum/beacon-APIs#615 as merged: - Nodes carry justified and finalized checkpoints. They are kept as the new ForkChoiceNodeV2 justified_checkpoint / finalized_checkpoint fields and, as before, as justified_epoch / finalized_epoch, and are round-tripped into extra_data for consumers of the v1 shape. - Weight and the PTC counts are always set. go-eth2-client now rejects v2 responses that do not follow the spec, which is every client today. Any v2 failure, not only an unsupported endpoint, now falls back to v1 for forkChoiceV2RetryInterval, so such nodes keep producing fork choice data without a warning on every fetch. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 2 + pkg/proto/eth/v1/fork_choice.go | 66 +++++++------ pkg/proto/eth/v1/fork_choice.pb.go | 52 ++++++++--- pkg/proto/eth/v1/fork_choice.proto | 6 ++ pkg/proto/eth/v1/fork_choice_test.go | 77 ++++++++------- pkg/proto/eth/v1/fork_choice_vtproto.pb.go | 104 +++++++++++++++++++++ pkg/sentry/fork_choice.go | 14 ++- 8 files changed, 242 insertions(+), 81 deletions(-) diff --git a/go.mod b/go.mod index 1d40a16f..5c9e3696 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55 + github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index 67ea708f..d95b4dcb 100644 --- a/go.sum +++ b/go.sum @@ -225,6 +225,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55 h1:W1Tc0Vd2JTQheYcKhTQJUzjJgdSqq9GTd1aTfj2f8jE= github.com/ethpandaops/go-eth2-client v0.1.9-0.20261007031839-0b35f0981d55/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb h1:nERZqyCYV2zMfdUSc5dchwGoUuv61rDVHsu39o2YftY= +github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/pkg/proto/eth/v1/fork_choice.go b/pkg/proto/eth/v1/fork_choice.go index 26555526..cced8be2 100644 --- a/pkg/proto/eth/v1/fork_choice.go +++ b/pkg/proto/eth/v1/fork_choice.go @@ -2,6 +2,7 @@ package v1 import ( "encoding/json" + "strconv" eth2v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" @@ -180,6 +181,18 @@ func (f *ForkChoiceNodeV2) AsGoEth2ClientV1ForkChoiceNode() (*eth2v1.ForkChoiceN } } + for key, checkpoint := range map[string]*CheckpointV2{ + "justified_checkpoint": f.GetJustifiedCheckpoint(), + "finalized_checkpoint": f.GetFinalizedCheckpoint(), + } { + if _, exists := extraData[key]; !exists && checkpoint != nil { + extraData[key] = map[string]any{ + "epoch": strconv.FormatUint(checkpoint.GetEpoch().GetValue(), 10), + "root": checkpoint.GetRoot(), + } + } + } + validity, err := eth2v1.ForkChoiceNodeValidityFromString(f.Validity) if err != nil { return nil, errors.Wrap(err, "failed to convert validity") @@ -361,21 +374,16 @@ func NewForkChoiceV2FromGoEth2ClientV2(f *eth2v1.ForkChoiceV2) (*ForkChoiceV2, e } return &ForkChoiceV2{ - FinalizedCheckpoint: &CheckpointV2{ - Root: RootAsString(f.FinalizedCheckpoint.Root), - Epoch: &wrapperspb.UInt64Value{Value: uint64(f.FinalizedCheckpoint.Epoch)}, - }, - JustifiedCheckpoint: &CheckpointV2{ - Root: RootAsString(f.JustifiedCheckpoint.Root), - Epoch: &wrapperspb.UInt64Value{Value: uint64(f.JustifiedCheckpoint.Epoch)}, - }, - ForkChoiceNodes: nodes, - ExtraData: extraData, + FinalizedCheckpoint: newCheckpointV2(f.FinalizedCheckpoint), + JustifiedCheckpoint: newCheckpointV2(f.JustifiedCheckpoint), + ForkChoiceNodes: nodes, + ExtraData: extraData, }, nil } // NewForkChoiceNodeV2FromGoEth2ClientV2 converts a v2 debug fork choice node. -// Values the client does not provide are left unset. +// The node's checkpoints are kept both as checkpoints and, like v1 nodes, as +// justified_epoch and finalized_epoch. func NewForkChoiceNodeV2FromGoEth2ClientV2(node *eth2v1.ForkChoiceNodeV2) (*ForkChoiceNodeV2, error) { extraData, err := json.Marshal(node.ExtraData) if err != nil { @@ -386,21 +394,17 @@ func NewForkChoiceNodeV2FromGoEth2ClientV2(node *eth2v1.ForkChoiceNodeV2) (*Fork Slot: &wrapperspb.UInt64Value{Value: uint64(node.Slot)}, BlockRoot: RootAsString(node.BlockRoot), ParentRoot: RootAsString(node.ParentRoot), - Weight: &wrapperspb.UInt64Value{Value: node.Weight}, + JustifiedEpoch: &wrapperspb.UInt64Value{Value: uint64(node.JustifiedCheckpoint.Epoch)}, + FinalizedEpoch: &wrapperspb.UInt64Value{Value: uint64(node.FinalizedCheckpoint.Epoch)}, + JustifiedCheckpoint: newCheckpointV2(node.JustifiedCheckpoint), + FinalizedCheckpoint: newCheckpointV2(node.FinalizedCheckpoint), + Weight: &wrapperspb.UInt64Value{Value: uint64(node.Weight)}, Validity: node.Validity.String(), ExecutionBlockHash: RootAsString(phase0.Root(node.ExecutionBlockHash)), ExtraData: string(extraData), - PayloadAttesterCount: optionalUint64Value(node.PayloadAttesterCount), - PayloadAvailabilityYesCount: optionalUint64Value(node.PayloadAvailabilityYesCount), - PayloadDataAvailabilityYesCount: optionalUint64Value(node.PayloadDataAvailabilityYesCount), - } - - if node.JustifiedEpoch != nil { - out.JustifiedEpoch = &wrapperspb.UInt64Value{Value: uint64(*node.JustifiedEpoch)} - } - - if node.FinalizedEpoch != nil { - out.FinalizedEpoch = &wrapperspb.UInt64Value{Value: uint64(*node.FinalizedEpoch)} + PayloadAttesterCount: &wrapperspb.UInt64Value{Value: node.PayloadAttesterCount}, + PayloadAvailabilityYesCount: &wrapperspb.UInt64Value{Value: node.PayloadAvailabilityYesCount}, + PayloadDataAvailabilityYesCount: &wrapperspb.UInt64Value{Value: node.PayloadDataAvailabilityYesCount}, } if v, ok := payloadStatusValue(node.PayloadStatus); ok { @@ -416,6 +420,14 @@ func NewForkChoiceNodeV2FromGoEth2ClientV2(node *eth2v1.ForkChoiceNodeV2) (*Fork return out, nil } +// newCheckpointV2 converts a go-eth2-client checkpoint. +func newCheckpointV2(checkpoint phase0.Checkpoint) *CheckpointV2 { + return &CheckpointV2{ + Root: RootAsString(checkpoint.Root), + Epoch: &wrapperspb.UInt64Value{Value: uint64(checkpoint.Epoch)}, + } +} + // payloadStatusValue maps a beacon API payload status onto the spec's // PayloadStatus enum (PAYLOAD_STATUS_EMPTY = 0, FULL = 1, PENDING = 2). func payloadStatusValue(status eth2v1.ForkChoicePayloadStatus) (uint32, bool) { @@ -431,14 +443,6 @@ func payloadStatusValue(status eth2v1.ForkChoicePayloadStatus) (uint32, bool) { } } -func optionalUint64Value(value *uint64) *wrapperspb.UInt64Value { - if value == nil { - return nil - } - - return &wrapperspb.UInt64Value{Value: *value} -} - // marshalExtraData encodes a fork choice store's extra data, leaving it empty // when the client provides none. func marshalExtraData(extraData map[string]any) (string, error) { diff --git a/pkg/proto/eth/v1/fork_choice.pb.go b/pkg/proto/eth/v1/fork_choice.pb.go index 59c79694..dfae8c87 100644 --- a/pkg/proto/eth/v1/fork_choice.pb.go +++ b/pkg/proto/eth/v1/fork_choice.pb.go @@ -305,6 +305,11 @@ type ForkChoiceNodeV2 struct { PayloadAttesterCount *wrapperspb.UInt64Value `protobuf:"bytes,12,opt,name=payload_attester_count,proto3" json:"payload_attester_count,omitempty"` PayloadAvailabilityYesCount *wrapperspb.UInt64Value `protobuf:"bytes,13,opt,name=payload_availability_yes_count,proto3" json:"payload_availability_yes_count,omitempty"` PayloadDataAvailabilityYesCount *wrapperspb.UInt64Value `protobuf:"bytes,14,opt,name=payload_data_availability_yes_count,proto3" json:"payload_data_availability_yes_count,omitempty"` + // Justified and finalized checkpoints recorded for the node, from GET + // /eth/v2/debug/fork_choice. justified_epoch and finalized_epoch carry the + // same epochs, so readers of v1 nodes need not change. + JustifiedCheckpoint *CheckpointV2 `protobuf:"bytes,15,opt,name=justified_checkpoint,proto3" json:"justified_checkpoint,omitempty"` + FinalizedCheckpoint *CheckpointV2 `protobuf:"bytes,16,opt,name=finalized_checkpoint,proto3" json:"finalized_checkpoint,omitempty"` } func (x *ForkChoiceNodeV2) Reset() { @@ -437,6 +442,20 @@ func (x *ForkChoiceNodeV2) GetPayloadDataAvailabilityYesCount() *wrapperspb.UInt return nil } +func (x *ForkChoiceNodeV2) GetJustifiedCheckpoint() *CheckpointV2 { + if x != nil { + return x.JustifiedCheckpoint + } + return nil +} + +func (x *ForkChoiceNodeV2) GetFinalizedCheckpoint() *CheckpointV2 { + if x != nil { + return x.FinalizedCheckpoint + } + return nil +} + var File_pkg_proto_eth_v1_fork_choice_proto protoreflect.FileDescriptor var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ @@ -502,7 +521,7 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x01, 0x28, 0x09, 0x52, 0x14, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1d, 0x0a, 0x09, 0x45, 0x78, 0x74, 0x72, 0x61, 0x44, 0x61, 0x74, 0x61, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, - 0x74, 0x72, 0x61, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x22, 0xff, 0x06, 0x0a, 0x10, 0x46, 0x6f, 0x72, + 0x74, 0x72, 0x61, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x22, 0x9d, 0x08, 0x0a, 0x10, 0x46, 0x6f, 0x72, 0x6b, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x56, 0x32, 0x12, 0x30, 0x0a, 0x04, 0x73, 0x6c, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, @@ -558,11 +577,20 @@ var file_pkg_proto_eth_v1_fork_choice_proto_rawDesc = []byte{ 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x55, 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x23, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, - 0x5f, 0x79, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x74, 0x68, 0x70, 0x61, 0x6e, 0x64, - 0x61, 0x6f, 0x70, 0x73, 0x2f, 0x78, 0x61, 0x74, 0x75, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x74, 0x68, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x5f, 0x79, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x4d, 0x0a, 0x14, 0x6a, 0x75, + 0x73, 0x74, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, + 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x61, 0x74, 0x75, 0x2e, + 0x65, 0x74, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, + 0x74, 0x56, 0x32, 0x52, 0x14, 0x6a, 0x75, 0x73, 0x74, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x63, + 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x4d, 0x0a, 0x14, 0x66, 0x69, 0x6e, + 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, + 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x61, 0x74, 0x75, 0x2e, 0x65, + 0x74, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x56, 0x32, 0x52, 0x14, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x63, 0x68, + 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x74, 0x68, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x6f, + 0x70, 0x73, 0x2f, 0x78, 0x61, 0x74, 0x75, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2f, 0x65, 0x74, 0x68, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -604,11 +632,13 @@ var file_pkg_proto_eth_v1_fork_choice_proto_depIdxs = []int32{ 6, // 12: xatu.eth.v1.ForkChoiceNodeV2.payload_attester_count:type_name -> google.protobuf.UInt64Value 6, // 13: xatu.eth.v1.ForkChoiceNodeV2.payload_availability_yes_count:type_name -> google.protobuf.UInt64Value 6, // 14: xatu.eth.v1.ForkChoiceNodeV2.payload_data_availability_yes_count:type_name -> google.protobuf.UInt64Value - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 5, // 15: xatu.eth.v1.ForkChoiceNodeV2.justified_checkpoint:type_name -> xatu.eth.v1.CheckpointV2 + 5, // 16: xatu.eth.v1.ForkChoiceNodeV2.finalized_checkpoint:type_name -> xatu.eth.v1.CheckpointV2 + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_pkg_proto_eth_v1_fork_choice_proto_init() } diff --git a/pkg/proto/eth/v1/fork_choice.proto b/pkg/proto/eth/v1/fork_choice.proto index d0439930..9a82c802 100644 --- a/pkg/proto/eth/v1/fork_choice.proto +++ b/pkg/proto/eth/v1/fork_choice.proto @@ -104,4 +104,10 @@ message ForkChoiceNodeV2 { [ json_name = "payload_availability_yes_count" ]; google.protobuf.UInt64Value payload_data_availability_yes_count = 14 [ json_name = "payload_data_availability_yes_count" ]; + + // Justified and finalized checkpoints recorded for the node, from GET + // /eth/v2/debug/fork_choice. justified_epoch and finalized_epoch carry the + // same epochs, so readers of v1 nodes need not change. + CheckpointV2 justified_checkpoint = 15 [ json_name = "justified_checkpoint" ]; + CheckpointV2 finalized_checkpoint = 16 [ json_name = "finalized_checkpoint" ]; } diff --git a/pkg/proto/eth/v1/fork_choice_test.go b/pkg/proto/eth/v1/fork_choice_test.go index 9234ae92..59555506 100644 --- a/pkg/proto/eth/v1/fork_choice_test.go +++ b/pkg/proto/eth/v1/fork_choice_test.go @@ -14,45 +14,46 @@ func payloadStatusPtr(s eth2v1.ForkChoicePayloadStatus) *eth2v1.ForkChoicePayloa return &s } -func uint64Ptr(v uint64) *uint64 { - return &v -} - -func epochPtr(v phase0.Epoch) *phase0.Epoch { - return &v -} - func testForkChoiceV2() *eth2v1.ForkChoiceV2 { + justified := phase0.Checkpoint{Epoch: 3, Root: phase0.Root{0xa}} + finalized := phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x9}} + return ð2v1.ForkChoiceV2{ - JustifiedCheckpoint: phase0.Checkpoint{Epoch: 3, Root: phase0.Root{0xa}}, - FinalizedCheckpoint: phase0.Checkpoint{Epoch: 2, Root: phase0.Root{0x9}}, + JustifiedCheckpoint: justified, + FinalizedCheckpoint: finalized, ForkChoiceNodes: []*eth2v1.ForkChoiceNodeV2{ { - // a client that implements the spec shape + // a pending node whose parent is not retained in the tree Slot: 29, BlockRoot: phase0.Root{0xb}, PayloadStatus: eth2v1.ForkChoicePayloadStatusPending, ParentRoot: phase0.Root{0xa}, - ParentPayloadStatus: payloadStatusPtr(eth2v1.ForkChoicePayloadStatusFull), - JustifiedEpoch: epochPtr(3), - FinalizedEpoch: epochPtr(2), + JustifiedCheckpoint: justified, + FinalizedCheckpoint: finalized, Weight: 128, Validity: eth2v1.ForkChoiceNodeValidityValid, ExecutionBlockHash: phase0.Hash32{0x1}, - PayloadAttesterCount: uint64Ptr(512), - PayloadAvailabilityYesCount: uint64Ptr(498), - PayloadDataAvailabilityYesCount: uint64Ptr(497), + PayloadAttesterCount: 512, + PayloadAvailabilityYesCount: 498, + PayloadDataAvailabilityYesCount: 497, ExtraData: map[string]any{"state_root": "0xc0"}, }, { - // a client that leaves the optional fields out - Slot: 29, - BlockRoot: phase0.Root{0xb}, - PayloadStatus: eth2v1.ForkChoicePayloadStatusEmpty, - ParentRoot: phase0.Root{0xa}, - Weight: 0, - Validity: eth2v1.ForkChoiceNodeValidityValid, - ExecutionBlockHash: phase0.Hash32{0x1}, + // the block's empty node, pointing at its pending node + Slot: 29, + BlockRoot: phase0.Root{0xb}, + PayloadStatus: eth2v1.ForkChoicePayloadStatusEmpty, + ParentRoot: phase0.Root{0xb}, + ParentPayloadStatus: payloadStatusPtr(eth2v1.ForkChoicePayloadStatusPending), + JustifiedCheckpoint: justified, + FinalizedCheckpoint: finalized, + Weight: 0, + Validity: eth2v1.ForkChoiceNodeValidityOptimistic, + ExecutionBlockHash: phase0.Hash32{0x1}, + PayloadAttesterCount: 512, + PayloadAvailabilityYesCount: 498, + PayloadDataAvailabilityYesCount: 497, + ExtraData: map[string]any{}, }, }, ExtraData: map[string]any{"proposer_boost_root": "0xb0"}, @@ -70,9 +71,14 @@ func TestNewForkChoiceV2FromGoEth2ClientV2(t *testing.T) { pending := fc.GetForkChoiceNodes()[0] assert.Equal(t, uint32(2), pending.GetPayloadStatus().GetValue(), "PAYLOAD_STATUS_PENDING") - assert.Equal(t, uint32(1), pending.GetParentPayloadStatus().GetValue(), "PAYLOAD_STATUS_FULL") + assert.Nil(t, pending.GetParentPayloadStatus(), "parent not retained") assert.Equal(t, uint64(3), pending.GetJustifiedEpoch().GetValue()) assert.Equal(t, uint64(2), pending.GetFinalizedEpoch().GetValue()) + assert.Equal(t, uint64(3), pending.GetJustifiedCheckpoint().GetEpoch().GetValue()) + assert.Equal(t, RootAsString(phase0.Root{0xa}), pending.GetJustifiedCheckpoint().GetRoot()) + assert.Equal(t, uint64(2), pending.GetFinalizedCheckpoint().GetEpoch().GetValue()) + assert.Equal(t, RootAsString(phase0.Root{0x9}), pending.GetFinalizedCheckpoint().GetRoot()) + assert.Equal(t, uint64(128), pending.GetWeight().GetValue()) assert.Equal(t, uint64(512), pending.GetPayloadAttesterCount().GetValue()) assert.Equal(t, uint64(498), pending.GetPayloadAvailabilityYesCount().GetValue()) assert.Equal(t, uint64(497), pending.GetPayloadDataAvailabilityYesCount().GetValue()) @@ -80,12 +86,12 @@ func TestNewForkChoiceV2FromGoEth2ClientV2(t *testing.T) { assert.JSONEq(t, `{"state_root":"0xc0"}`, pending.GetExtraData()) empty := fc.GetForkChoiceNodes()[1] - assert.NotNil(t, empty.GetPayloadStatus()) assert.Equal(t, uint32(0), empty.GetPayloadStatus().GetValue(), "PAYLOAD_STATUS_EMPTY") - assert.Nil(t, empty.GetParentPayloadStatus()) - assert.Nil(t, empty.GetJustifiedEpoch()) - assert.Nil(t, empty.GetFinalizedEpoch()) - assert.Nil(t, empty.GetPayloadAttesterCount()) + assert.Equal(t, uint32(2), empty.GetParentPayloadStatus().GetValue(), "PAYLOAD_STATUS_PENDING") + assert.Equal(t, RootAsString(phase0.Root{0xb}), empty.GetParentRoot()) + assert.Equal(t, "optimistic", empty.GetValidity()) + require.NotNil(t, empty.GetWeight()) + assert.Equal(t, uint64(0), empty.GetWeight().GetValue()) } // Consumers that convert to the go-eth2-client v1 shape still see the v2 @@ -99,19 +105,22 @@ func TestForkChoiceV2RoundTripsV2FieldsIntoExtraData(t *testing.T) { assert.Equal(t, map[string]any{"proposer_boost_root": "0xb0"}, v1.ExtraData) require.Len(t, v1.ForkChoiceNodes, 2) + assert.Equal(t, phase0.Epoch(3), v1.ForkChoiceNodes[0].JustifiedEpoch) + assert.Equal(t, phase0.Epoch(2), v1.ForkChoiceNodes[0].FinalizedEpoch) extra, err := json.Marshal(v1.ForkChoiceNodes[0].ExtraData) require.NoError(t, err) assert.JSONEq(t, `{ "state_root": "0xc0", "payload_status": 2, - "parent_payload_status": 1, "payload_attester_count": 512, "payload_availability_yes_count": 498, - "payload_data_availability_yes_count": 497 + "payload_data_availability_yes_count": 497, + "justified_checkpoint": {"epoch": "3", "root": "`+RootAsString(phase0.Root{0xa})+`"}, + "finalized_checkpoint": {"epoch": "2", "root": "`+RootAsString(phase0.Root{0x9})+`"} }`, string(extra)) - assert.Equal(t, map[string]any{"payload_status": uint32(0)}, v1.ForkChoiceNodes[1].ExtraData) + assert.Equal(t, uint32(2), v1.ForkChoiceNodes[1].ExtraData["parent_payload_status"]) } func TestNewForkChoiceV2FromGoEth2ClientV1KeepsStoreExtraData(t *testing.T) { diff --git a/pkg/proto/eth/v1/fork_choice_vtproto.pb.go b/pkg/proto/eth/v1/fork_choice_vtproto.pb.go index 7361b321..08b6e0bc 100644 --- a/pkg/proto/eth/v1/fork_choice_vtproto.pb.go +++ b/pkg/proto/eth/v1/fork_choice_vtproto.pb.go @@ -276,6 +276,28 @@ func (m *ForkChoiceNodeV2) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.FinalizedCheckpoint != nil { + size, err := m.FinalizedCheckpoint.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1 + i-- + dAtA[i] = 0x82 + } + if m.JustifiedCheckpoint != nil { + size, err := m.JustifiedCheckpoint.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x7a + } if m.PayloadDataAvailabilityYesCount != nil { size, err := (*wrapperspb.UInt64Value)(m.PayloadDataAvailabilityYesCount).MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -489,6 +511,8 @@ var vtprotoPool_ForkChoiceNodeV2 = sync.Pool{ func (m *ForkChoiceNodeV2) ResetVT() { if m != nil { + m.JustifiedCheckpoint.ReturnToVTPool() + m.FinalizedCheckpoint.ReturnToVTPool() m.Reset() } } @@ -657,6 +681,14 @@ func (m *ForkChoiceNodeV2) SizeVT() (n int) { l = (*wrapperspb.UInt64Value)(m.PayloadDataAvailabilityYesCount).SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + if m.JustifiedCheckpoint != nil { + l = m.JustifiedCheckpoint.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + if m.FinalizedCheckpoint != nil { + l = m.FinalizedCheckpoint.SizeVT() + n += 2 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -1821,6 +1853,78 @@ func (m *ForkChoiceNodeV2) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 15: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field JustifiedCheckpoint", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.JustifiedCheckpoint == nil { + m.JustifiedCheckpoint = CheckpointV2FromVTPool() + } + if err := m.JustifiedCheckpoint.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 16: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field FinalizedCheckpoint", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.FinalizedCheckpoint == nil { + m.FinalizedCheckpoint = CheckpointV2FromVTPool() + } + if err := m.FinalizedCheckpoint.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/pkg/sentry/fork_choice.go b/pkg/sentry/fork_choice.go index 2b37fec3..496f0717 100644 --- a/pkg/sentry/fork_choice.go +++ b/pkg/sentry/fork_choice.go @@ -206,12 +206,16 @@ func (s *Sentry) fetchDecoratedDebugForkChoice(ctx context.Context) error { } // forkChoiceV2RetryInterval is how long to use the v1 fork choice endpoint -// before checking whether a beacon node has started supporting v2. +// after v2 failed before trying v2 again. const forkChoiceV2RetryInterval = time.Hour // fetchForkChoice fetches the beacon node's fork choice, preferring the // Gloas-aware GET /eth/v2/debug/fork_choice (one node per block root and -// payload status) and falling back to v1 for nodes that do not support it. +// payload status) and falling back to v1 when v2 fails. That covers nodes +// without the endpoint and nodes whose response does not follow the spec +// (ethereum/beacon-APIs#615); either way v1 is used for +// forkChoiceV2RetryInterval before v2 is tried again, so a node is not asked +// for, and does not log, a failing v2 fork choice on every fetch. // Exactly one of the returned fork choices is set. func (s *Sentry) fetchForkChoice(ctx context.Context) (*eth2v1.ForkChoice, *eth2v1.ForkChoiceV2, error) { if provider, isProvider := s.beacon.Node().Service().(eth2client.ForkChoiceV2Provider); isProvider && @@ -221,11 +225,13 @@ func (s *Sentry) fetchForkChoice(ctx context.Context) (*eth2v1.ForkChoice, *eth2 return nil, rsp.Data, nil } + s.forkChoiceV2RetryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) + if isUnsupportedEndpoint(err) { s.log.WithError(err).Debug("Beacon node does not support the v2 fork choice endpoint, falling back to v1") - s.forkChoiceV2RetryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) } else { - s.log.WithError(err).Warn("Failed to fetch v2 fork choice, falling back to v1") + s.log.WithError(err).WithField("retry_in", forkChoiceV2RetryInterval). + Warn("Failed to fetch v2 fork choice, falling back to v1") } } From e01ccc2fabf673a5285d97a87c75612c9faf1525 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:18:10 +1000 Subject: [PATCH 09/11] chore(deps): bump go-eth2-client to 7a87b26e Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5c9e3696..54f69448 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb + github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5 github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index 3793219d..8e63b8d7 100644 --- a/go.sum +++ b/go.sum @@ -225,6 +225,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb h1:nERZqyCYV2zMfdUSc5dchwGoUuv61rDVHsu39o2YftY= github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5 h1:5EeGn0zDLncslF81vyNR8PxAwcsTWyxZ7RvE4l7TE7A= +github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From 41d9f413dbb16c79413b466493cece97ea6c9520 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:26:23 +1000 Subject: [PATCH 10/11] chore(deps): bump go-eth2-client to v0.1.10 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 54f69448..33050810 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/ethpandaops/beacon v0.71.0 github.com/ethpandaops/ethcore v0.0.0-20260807105509-b0a20c27b6e7 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5 + github.com/ethpandaops/go-eth2-client v0.1.10 github.com/failsafe-go/failsafe-go v0.9.6 github.com/ferranbt/fastssz v1.0.0 github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/go.sum b/go.sum index 8e63b8d7..3cb6645d 100644 --- a/go.sum +++ b/go.sum @@ -223,10 +223,8 @@ github.com/ethpandaops/ethereum-package-go v0.10.0 h1:q/TTZGWMjZmcuweASv14TEXxdP github.com/ethpandaops/ethereum-package-go v0.10.0/go.mod h1:t/JxwHlHCATh0FfQTiE5+kn1yg6wQ3NJtiBQ0n5MW84= github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1yat+ZyHh1g= github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= -github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb h1:nERZqyCYV2zMfdUSc5dchwGoUuv61rDVHsu39o2YftY= -github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008034357-9de0cf34adeb/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= -github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5 h1:5EeGn0zDLncslF81vyNR8PxAwcsTWyxZ7RvE4l7TE7A= -github.com/ethpandaops/go-eth2-client v0.1.10-0.20261008041707-7a87b26e60b5/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= +github.com/ethpandaops/go-eth2-client v0.1.10 h1:AGROjkS5jMkveL1E46wyWNv5JLJNWWyZE/hIVBibIrg= +github.com/ethpandaops/go-eth2-client v0.1.10/go.mod h1:sIVbaug0GsltsJS+hj8ID4dmWBIt8TXoNIesvuBCGt0= github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= From a71a264dc8d711aea1599f9d52bd6c82b5dca00d Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:45:55 +1000 Subject: [PATCH 11/11] fix(sentry): only back off v2 fork choice for nodes that cannot serve it Review follow-ups: - Back off to v1 for an hour only when the node lacks the endpoint or its v2 response does not follow the spec (go-eth2-client's ErrInvalidResponse). A timeout, 5xx or connection error falls back to v1 for that fetch only, and a cancelled fetch returns without falling back. - Move the fallback into fetchForkChoiceWithFallback and test it: v2 served, each failure kind, the back-off window, no provider, v1 errors and cancellation. - Decoded v2 values take precedence over a client's extra_data under the same keys, for the counts and checkpoints as for payload_status. - Test the null node extra_data round trip; note that a re-org event's before/after snapshots can come from different endpoints. Co-Authored-By: Claude Opus 5.5 --- pkg/proto/eth/v1/fork_choice.go | 17 ++-- pkg/proto/eth/v1/fork_choice_test.go | 37 ++++++++ pkg/sentry/fork_choice.go | 60 +++++++++---- pkg/sentry/fork_choice_test.go | 124 +++++++++++++++++++++++++++ pkg/sentry/sentry.go | 3 +- 5 files changed, 219 insertions(+), 22 deletions(-) diff --git a/pkg/proto/eth/v1/fork_choice.go b/pkg/proto/eth/v1/fork_choice.go index cced8be2..d579b6f6 100644 --- a/pkg/proto/eth/v1/fork_choice.go +++ b/pkg/proto/eth/v1/fork_choice.go @@ -162,7 +162,8 @@ func (f *ForkChoiceNodeV2) AsGoEth2ClientV1ForkChoiceNode() (*eth2v1.ForkChoiceN // Round-trip the EIP-7732 payload_status and the other v2 fork choice // fields into extra_data so downstream consumers that only inspect - // extra_data still see them. + // extra_data still see them. The decoded values take precedence over any + // the client put in extra_data under the same keys. if ps := f.GetPayloadStatus(); ps != nil { extraData["payload_status"] = ps.GetValue() } @@ -176,7 +177,7 @@ func (f *ForkChoiceNodeV2) AsGoEth2ClientV1ForkChoiceNode() (*eth2v1.ForkChoiceN "payload_availability_yes_count": f.GetPayloadAvailabilityYesCount(), "payload_data_availability_yes_count": f.GetPayloadDataAvailabilityYesCount(), } { - if _, exists := extraData[key]; !exists && value != nil { + if value != nil { extraData[key] = value.GetValue() } } @@ -185,10 +186,10 @@ func (f *ForkChoiceNodeV2) AsGoEth2ClientV1ForkChoiceNode() (*eth2v1.ForkChoiceN "justified_checkpoint": f.GetJustifiedCheckpoint(), "finalized_checkpoint": f.GetFinalizedCheckpoint(), } { - if _, exists := extraData[key]; !exists && checkpoint != nil { + if checkpoint != nil { extraData[key] = map[string]any{ - "epoch": strconv.FormatUint(checkpoint.GetEpoch().GetValue(), 10), - "root": checkpoint.GetRoot(), + checkpointEpochKey: strconv.FormatUint(checkpoint.GetEpoch().GetValue(), 10), + checkpointRootKey: checkpoint.GetRoot(), } } } @@ -420,6 +421,12 @@ func NewForkChoiceNodeV2FromGoEth2ClientV2(node *eth2v1.ForkChoiceNodeV2) (*Fork return out, nil } +// Keys of a checkpoint in the beacon API's JSON encoding. +const ( + checkpointEpochKey = "epoch" + checkpointRootKey = "root" +) + // newCheckpointV2 converts a go-eth2-client checkpoint. func newCheckpointV2(checkpoint phase0.Checkpoint) *CheckpointV2 { return &CheckpointV2{ diff --git a/pkg/proto/eth/v1/fork_choice_test.go b/pkg/proto/eth/v1/fork_choice_test.go index 59555506..db4ec53d 100644 --- a/pkg/proto/eth/v1/fork_choice_test.go +++ b/pkg/proto/eth/v1/fork_choice_test.go @@ -8,6 +8,7 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/wrapperspb" ) func payloadStatusPtr(s eth2v1.ForkChoicePayloadStatus) *eth2v1.ForkChoicePayloadStatus { @@ -139,3 +140,39 @@ func TestNewForkChoiceV2FromGoEth2ClientV1KeepsStoreExtraData(t *testing.T) { require.NoError(t, err) assert.Empty(t, fc.GetExtraData()) } + +// Decoded v2 values take precedence over a client's extra_data under the same keys. +func TestForkChoiceV2RoundTripDecodedValuesWin(t *testing.T) { + forkChoice := testForkChoiceV2() + forkChoice.ForkChoiceNodes[0].ExtraData = map[string]any{ + "payload_status": "FULL", + "payload_attester_count": "7", + "justified_checkpoint": "client value", + } + + fc, err := NewForkChoiceV2FromGoEth2ClientV2(forkChoice) + require.NoError(t, err) + + v1, err := fc.AsGoEth2ClientV1ForkChoice() + require.NoError(t, err) + + extra := v1.ForkChoiceNodes[0].ExtraData + assert.Equal(t, uint32(2), extra["payload_status"]) + assert.Equal(t, uint64(512), extra["payload_attester_count"]) + assert.Equal(t, map[string]any{checkpointEpochKey: "3", checkpointRootKey: RootAsString(phase0.Root{0xa})}, extra["justified_checkpoint"]) +} + +// A v1 node without extra data is encoded as JSON null; converting it back must not panic. +func TestForkChoiceNodeV2NullExtraData(t *testing.T) { + fc, err := NewForkChoiceV2FromGoEth2ClientV1(ð2v1.ForkChoice{ + ForkChoiceNodes: []*eth2v1.ForkChoiceNode{{Slot: 1, Validity: eth2v1.ForkChoiceNodeValidityValid}}, + }) + require.NoError(t, err) + require.Equal(t, "null", fc.GetForkChoiceNodes()[0].GetExtraData()) + + fc.GetForkChoiceNodes()[0].PayloadStatus = &wrapperspb.UInt32Value{Value: 1} + + v1, err := fc.AsGoEth2ClientV1ForkChoice() + require.NoError(t, err) + assert.Equal(t, map[string]any{"payload_status": uint32(1)}, v1.ForkChoiceNodes[0].ExtraData) +} diff --git a/pkg/sentry/fork_choice.go b/pkg/sentry/fork_choice.go index 496f0717..e5664ed1 100644 --- a/pkg/sentry/fork_choice.go +++ b/pkg/sentry/fork_choice.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "sync/atomic" "time" "github.com/ethpandaops/ethwallclock" @@ -12,6 +13,7 @@ import ( eth2v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/go-co-op/gocron/v2" + "github.com/sirupsen/logrus" xatuethv1 "github.com/ethpandaops/xatu/pkg/proto/eth/v1" v1 "github.com/ethpandaops/xatu/pkg/sentry/event/beacon/eth/v1" @@ -158,6 +160,9 @@ func (s *Sentry) fetchDebugForkChoice(ctx context.Context) (*v1.ForkChoice, erro RequestEpoch: phase0.Epoch(epoch.Number()), } + // A re-org event's before and after snapshots can come from different + // endpoints when the v2 retry window opens or closes between them: v2 + // labels each block's empty/full/pending nodes, v1 does not. forkChoice, forkChoiceV2, err := s.fetchForkChoice(ctx) if err != nil { return nil, err @@ -206,36 +211,59 @@ func (s *Sentry) fetchDecoratedDebugForkChoice(ctx context.Context) error { } // forkChoiceV2RetryInterval is how long to use the v1 fork choice endpoint -// after v2 failed before trying v2 again. +// after the beacon node showed it cannot serve v2 before trying v2 again. const forkChoiceV2RetryInterval = time.Hour // fetchForkChoice fetches the beacon node's fork choice, preferring the // Gloas-aware GET /eth/v2/debug/fork_choice (one node per block root and -// payload status) and falling back to v1 when v2 fails. That covers nodes -// without the endpoint and nodes whose response does not follow the spec -// (ethereum/beacon-APIs#615); either way v1 is used for -// forkChoiceV2RetryInterval before v2 is tried again, so a node is not asked -// for, and does not log, a failing v2 fork choice on every fetch. -// Exactly one of the returned fork choices is set. +// payload status) and falling back to v1. Exactly one of the returned fork +// choices is set. func (s *Sentry) fetchForkChoice(ctx context.Context) (*eth2v1.ForkChoice, *eth2v1.ForkChoiceV2, error) { - if provider, isProvider := s.beacon.Node().Service().(eth2client.ForkChoiceV2Provider); isProvider && - time.Now().UnixNano() >= s.forkChoiceV2RetryAt.Load() { + provider, _ := s.beacon.Node().Service().(eth2client.ForkChoiceV2Provider) + + return fetchForkChoiceWithFallback(ctx, s.log, provider, s.beacon.Node().FetchForkChoice, &s.forkChoiceV2RetryAt) +} + +// fetchForkChoiceWithFallback fetches the v2 fork choice from provider (if not +// nil, and not backing off), falling back to fetchV1. +// +// A node without the endpoint, or whose v2 response does not follow the spec +// (ethereum/beacon-APIs#615), cannot serve v2 until it is upgraded, so v1 is +// used for forkChoiceV2RetryInterval before v2 is tried again; that keeps such +// a node from being asked for, and logging, a failing v2 fork choice on every +// fetch. Any other v2 failure (a timeout, a 5xx) falls back to v1 for this +// fetch only. +func fetchForkChoiceWithFallback( + ctx context.Context, + log logrus.FieldLogger, + provider eth2client.ForkChoiceV2Provider, + fetchV1 func(context.Context) (*eth2v1.ForkChoice, error), + retryAt *atomic.Int64, +) (*eth2v1.ForkChoice, *eth2v1.ForkChoiceV2, error) { + if provider != nil && time.Now().UnixNano() >= retryAt.Load() { rsp, err := provider.ForkChoiceV2(ctx, &api.ForkChoiceOpts{}) if err == nil { return nil, rsp.Data, nil } - s.forkChoiceV2RetryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, nil, ctxErr + } - if isUnsupportedEndpoint(err) { - s.log.WithError(err).Debug("Beacon node does not support the v2 fork choice endpoint, falling back to v1") - } else { - s.log.WithError(err).WithField("retry_in", forkChoiceV2RetryInterval). - Warn("Failed to fetch v2 fork choice, falling back to v1") + switch { + case isUnsupportedEndpoint(err): + retryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) + log.WithError(err).Debug("Beacon node does not support the v2 fork choice endpoint, using v1") + case errors.Is(err, eth2client.ErrInvalidResponse): + retryAt.Store(time.Now().Add(forkChoiceV2RetryInterval).UnixNano()) + log.WithError(err).WithField("retry_in", forkChoiceV2RetryInterval). + Warn("Beacon node's v2 fork choice does not follow the spec, using v1") + default: + log.WithError(err).Warn("Failed to fetch v2 fork choice, falling back to v1 for this fetch") } } - forkChoice, err := s.beacon.Node().FetchForkChoice(ctx) + forkChoice, err := fetchV1(ctx) if err != nil { return nil, nil, err } diff --git a/pkg/sentry/fork_choice_test.go b/pkg/sentry/fork_choice_test.go index 3dfc6306..0cdefb0d 100644 --- a/pkg/sentry/fork_choice_test.go +++ b/pkg/sentry/fork_choice_test.go @@ -1,13 +1,20 @@ package sentry import ( + "context" "errors" "fmt" "net/http" + "sync/atomic" "testing" + "time" + eth2client "github.com/ethpandaops/go-eth2-client" "github.com/ethpandaops/go-eth2-client/api" + eth2v1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsUnsupportedEndpoint(t *testing.T) { @@ -31,3 +38,120 @@ func TestIsUnsupportedEndpoint(t *testing.T) { }) } } + +// stubForkChoiceV2 is a ForkChoiceV2Provider returning fixed results. +type stubForkChoiceV2 struct { + calls int + data *eth2v1.ForkChoiceV2 + err error +} + +func (s *stubForkChoiceV2) ForkChoiceV2(context.Context, *api.ForkChoiceOpts) (*api.Response[*eth2v1.ForkChoiceV2], error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + + return &api.Response[*eth2v1.ForkChoiceV2]{Data: s.data}, nil +} + +func TestFetchForkChoiceWithFallback(t *testing.T) { + v1ForkChoice := ð2v1.ForkChoice{} + v2ForkChoice := ð2v1.ForkChoiceV2{} + invalid := fmt.Errorf("%w: fork choice data missing", eth2client.ErrInvalidResponse) + + tests := []struct { + name string + v2Err error + wantV2 bool + wantRetry bool + }{ + {name: "v2 served", wantV2: true}, + {name: "v2 unsupported", v2Err: &api.Error{StatusCode: http.StatusNotFound}, wantRetry: true}, + {name: "v2 response not following the spec", v2Err: invalid, wantRetry: true}, + {name: "v2 transient failure", v2Err: &api.Error{StatusCode: http.StatusServiceUnavailable}}, + {name: "v2 connection failure", v2Err: errors.New("connection refused")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var retryAt atomic.Int64 + + provider := &stubForkChoiceV2{data: v2ForkChoice, err: test.v2Err} + v1Calls := 0 + fetchV1 := func(context.Context) (*eth2v1.ForkChoice, error) { + v1Calls++ + + return v1ForkChoice, nil + } + + forkChoice, forkChoiceV2, err := fetchForkChoiceWithFallback(context.Background(), logrus.New(), provider, fetchV1, &retryAt) + require.NoError(t, err) + + if test.wantV2 { + assert.Same(t, v2ForkChoice, forkChoiceV2) + assert.Nil(t, forkChoice) + assert.Zero(t, v1Calls) + } else { + assert.Same(t, v1ForkChoice, forkChoice) + assert.Nil(t, forkChoiceV2) + assert.Equal(t, 1, v1Calls) + } + + // Only a node that cannot serve v2 backs off; otherwise the next fetch tries v2 again. + _, _, err = fetchForkChoiceWithFallback(context.Background(), logrus.New(), provider, fetchV1, &retryAt) + require.NoError(t, err) + + wantCalls := 2 + + if test.wantRetry { + wantCalls = 1 + + assert.Greater(t, retryAt.Load(), time.Now().UnixNano()) + } + + assert.Equal(t, wantCalls, provider.calls) + }) + } +} + +func TestFetchForkChoiceWithFallbackNoProvider(t *testing.T) { + var retryAt atomic.Int64 + + v1ForkChoice := ð2v1.ForkChoice{} + forkChoice, forkChoiceV2, err := fetchForkChoiceWithFallback(context.Background(), logrus.New(), nil, + func(context.Context) (*eth2v1.ForkChoice, error) { return v1ForkChoice, nil }, &retryAt) + require.NoError(t, err) + assert.Same(t, v1ForkChoice, forkChoice) + assert.Nil(t, forkChoiceV2) +} + +// A v1 failure is returned, not masked by the fallback. +func TestFetchForkChoiceWithFallbackV1Error(t *testing.T) { + var retryAt atomic.Int64 + + v1Err := errors.New("v1 failed") + _, _, err := fetchForkChoiceWithFallback(context.Background(), logrus.New(), + &stubForkChoiceV2{err: &api.Error{StatusCode: http.StatusNotFound}}, + func(context.Context) (*eth2v1.ForkChoice, error) { return nil, v1Err }, &retryAt) + require.ErrorIs(t, err, v1Err) +} + +// A cancelled fetch neither backs off nor falls back to v1. +func TestFetchForkChoiceWithFallbackCancelled(t *testing.T) { + var retryAt atomic.Int64 + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + v1Calls := 0 + _, _, err := fetchForkChoiceWithFallback(ctx, logrus.New(), &stubForkChoiceV2{err: context.Canceled}, + func(context.Context) (*eth2v1.ForkChoice, error) { + v1Calls++ + + return ð2v1.ForkChoice{}, nil + }, &retryAt) + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, v1Calls) + assert.Zero(t, retryAt.Load()) +} diff --git a/pkg/sentry/sentry.go b/pkg/sentry/sentry.go index 25f696be..9ad1644e 100644 --- a/pkg/sentry/sentry.go +++ b/pkg/sentry/sentry.go @@ -73,7 +73,8 @@ type Sentry struct { latestForkChoiceMu sync.RWMutex // forkChoiceV2RetryAt is when to next try the v2 fork choice endpoint, in - // unix nanoseconds, after the beacon node reported it as unsupported. + // unix nanoseconds, after the beacon node showed it cannot serve v2 (no + // endpoint, or a response that does not follow the spec). forkChoiceV2RetryAt atomic.Int64 preset *Preset