diff --git a/go.mod b/go.mod index e6b1853c..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.9 + 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 f4cc76cb..3cb6645d 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 h1:HUTD75jjrquxR6ND0hvKJ7cEZLD7eLj6MzKa3vNspZI= -github.com/ethpandaops/go-eth2-client v0.1.9/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= diff --git a/pkg/proto/eth/v1/fork_choice.go b/pkg/proto/eth/v1/fork_choice.go index f9fa0922..d579b6f6 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" @@ -68,6 +69,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 +86,7 @@ func (f *ForkChoiceV2) AsGoEth2ClientV1ForkChoice() (*eth2v1.ForkChoice, error) Root: finalizedRoot, }, ForkChoiceNodes: nodes, + ExtraData: extraData, }, nil } @@ -145,12 +154,46 @@ 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. 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() } + 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 value != nil { + extraData[key] = value.GetValue() + } + } + + for key, checkpoint := range map[string]*CheckpointV2{ + "justified_checkpoint": f.GetJustifiedCheckpoint(), + "finalized_checkpoint": f.GetFinalizedCheckpoint(), + } { + if checkpoint != nil { + extraData[key] = map[string]any{ + checkpointEpochKey: strconv.FormatUint(checkpoint.GetEpoch().GetValue(), 10), + checkpointRootKey: checkpoint.GetRoot(), + } + } + } + validity, err := eth2v1.ForkChoiceNodeValidityFromString(f.Validity) if err != nil { return nil, errors.Wrap(err, "failed to convert validity") @@ -195,6 +238,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 +268,7 @@ func NewForkChoiceV2FromGoEth2ClientV1(f *eth2v1.ForkChoice) (*ForkChoiceV2, err }, }, ForkChoiceNodes: nodes, + ExtraData: extraData, }, nil } @@ -304,3 +353,114 @@ 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: newCheckpointV2(f.FinalizedCheckpoint), + JustifiedCheckpoint: newCheckpointV2(f.JustifiedCheckpoint), + ForkChoiceNodes: nodes, + ExtraData: extraData, + }, nil +} + +// NewForkChoiceNodeV2FromGoEth2ClientV2 converts a v2 debug fork choice node. +// 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 { + return nil, err + } + + out := &ForkChoiceNodeV2{ + Slot: &wrapperspb.UInt64Value{Value: uint64(node.Slot)}, + BlockRoot: RootAsString(node.BlockRoot), + ParentRoot: RootAsString(node.ParentRoot), + 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: &wrapperspb.UInt64Value{Value: node.PayloadAttesterCount}, + PayloadAvailabilityYesCount: &wrapperspb.UInt64Value{Value: node.PayloadAvailabilityYesCount}, + PayloadDataAvailabilityYesCount: &wrapperspb.UInt64Value{Value: node.PayloadDataAvailabilityYesCount}, + } + + 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 +} + +// 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{ + 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) { + switch status { + case eth2v1.ForkChoicePayloadStatusEmpty: + return 0, true + case eth2v1.ForkChoicePayloadStatusFull: + return 1, true + case eth2v1.ForkChoicePayloadStatusPending: + return 2, true + default: + return 0, false + } +} + +// 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..dfae8c87 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,22 @@ 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"` + // 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() { @@ -386,6 +414,48 @@ 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 +} + +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{ @@ -413,7 +483,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 +499,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 +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, 0x03, 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, @@ -481,11 +553,44 @@ 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, - 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, + 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, 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 ( @@ -523,11 +628,17 @@ 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 + 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 9de8f1dc..9a82c802 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,32 @@ 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" ]; + + // 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 new file mode 100644 index 00000000..db4ec53d --- /dev/null +++ b/pkg/proto/eth/v1/fork_choice_test.go @@ -0,0 +1,178 @@ +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" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func payloadStatusPtr(s eth2v1.ForkChoicePayloadStatus) *eth2v1.ForkChoicePayloadStatus { + return &s +} + +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: justified, + FinalizedCheckpoint: finalized, + ForkChoiceNodes: []*eth2v1.ForkChoiceNodeV2{ + { + // a pending node whose parent is not retained in the tree + Slot: 29, + BlockRoot: phase0.Root{0xb}, + PayloadStatus: eth2v1.ForkChoicePayloadStatusPending, + ParentRoot: phase0.Root{0xa}, + JustifiedCheckpoint: justified, + FinalizedCheckpoint: finalized, + Weight: 128, + Validity: eth2v1.ForkChoiceNodeValidityValid, + ExecutionBlockHash: phase0.Hash32{0x1}, + PayloadAttesterCount: 512, + PayloadAvailabilityYesCount: 498, + PayloadDataAvailabilityYesCount: 497, + ExtraData: map[string]any{"state_root": "0xc0"}, + }, + { + // 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"}, + } +} + +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.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()) + assert.Equal(t, RootAsString(phase0.Root{0x1}), pending.GetExecutionBlockHash()) + assert.JSONEq(t, `{"state_root":"0xc0"}`, pending.GetExtraData()) + + empty := fc.GetForkChoiceNodes()[1] + assert.Equal(t, uint32(0), empty.GetPayloadStatus().GetValue(), "PAYLOAD_STATUS_EMPTY") + 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 +// 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) + 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, + "payload_attester_count": 512, + "payload_availability_yes_count": 498, + "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, uint32(2), v1.ForkChoiceNodes[1].ExtraData["parent_payload_status"]) +} + +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()) +} + +// 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/proto/eth/v1/fork_choice_vtproto.pb.go b/pkg/proto/eth/v1/fork_choice_vtproto.pb.go index 821cf317..08b6e0bc 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,68 @@ 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 { + 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 { @@ -442,6 +511,8 @@ var vtprotoPool_ForkChoiceNodeV2 = sync.Pool{ func (m *ForkChoiceNodeV2) ResetVT() { if m != nil { + m.JustifiedCheckpoint.ReturnToVTPool() + m.FinalizedCheckpoint.ReturnToVTPool() m.Reset() } } @@ -498,6 +569,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 +665,30 @@ 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)) + } + 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 } @@ -900,6 +999,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 +1709,222 @@ 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 + 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/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..e5664ed1 100644 --- a/pkg/sentry/fork_choice.go +++ b/pkg/sentry/fork_choice.go @@ -2,12 +2,18 @@ package sentry import ( "context" + "errors" + "net/http" + "sync/atomic" "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" + "github.com/sirupsen/logrus" xatuethv1 "github.com/ethpandaops/xatu/pkg/proto/eth/v1" v1 "github.com/ethpandaops/xatu/pkg/sentry/event/beacon/eth/v1" @@ -154,7 +160,10 @@ func (s *Sentry) fetchDebugForkChoice(ctx context.Context) (*v1.ForkChoice, erro RequestEpoch: phase0.Epoch(epoch.Number()), } - forkChoice, err := s.beacon.Node().FetchForkChoice(ctx) + // 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 } @@ -166,6 +175,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 +209,80 @@ func (s *Sentry) fetchDecoratedDebugForkChoice(ctx context.Context) error { return s.handleNewDecoratedEvent(ctx, decoratedEvent) } + +// forkChoiceV2RetryInterval is how long to use the v1 fork choice endpoint +// 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. Exactly one of the returned fork +// choices is set. +func (s *Sentry) fetchForkChoice(ctx context.Context) (*eth2v1.ForkChoice, *eth2v1.ForkChoiceV2, error) { + 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 + } + + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, nil, ctxErr + } + + 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 := fetchV1(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..0cdefb0d --- /dev/null +++ b/pkg/sentry/fork_choice_test.go @@ -0,0 +1,157 @@ +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) { + 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)) + }) + } +} + +// 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 9ed9f784..9ad1644e 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,11 @@ 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 showed it cannot serve v2 (no + // endpoint, or a response that does not follow the spec). + forkChoiceV2RetryAt atomic.Int64 + preset *Preset shutdownFuncs []func(context.Context) error