diff --git a/api/eventsopts.go b/api/eventsopts.go index ecfee289..71c5dacd 100644 --- a/api/eventsopts.go +++ b/api/eventsopts.go @@ -70,6 +70,8 @@ type EventsOpts struct { FinalizedCheckpointHandler FinalizedCheckpointEventHandlerFunc // HeadHandler is a handler for the head event. HeadHandler HeadEventHandlerFunc + // HeadV2Handler is a handler for the head_v2 event. + HeadV2Handler HeadV2EventHandlerFunc // PayloadAttestationMessageHandler is a handler for the payload_attestation_message event. PayloadAttestationMessageHandler PayloadAttestationMessageEventHandlerFunc // PayloadAttributesHandler is a handler for the payload_attributes event. @@ -117,6 +119,9 @@ type FinalizedCheckpointEventHandlerFunc func(context.Context, *apiv1.FinalizedC // HeadEventHandlerFunc is the handler for head events. type HeadEventHandlerFunc func(context.Context, *apiv1.HeadEvent) +// HeadV2EventHandlerFunc is the handler for head_v2 events. +type HeadV2EventHandlerFunc func(context.Context, *apiv1.HeadEventV2) + // PayloadAttributesEventHandlerFunc is the handler for payload_attributes events. type PayloadAttributesEventHandlerFunc func(context.Context, *apiv1.PayloadAttributesEvent) diff --git a/api/v1/event.go b/api/v1/event.go index 12ad6cce..caca02d3 100644 --- a/api/v1/event.go +++ b/api/v1/event.go @@ -52,6 +52,7 @@ var SupportedEventTopics = map[string]bool{ "fast_confirmation": true, "finalized_checkpoint": true, "head": true, + "head_v2": true, "inclusion_list": true, "payload_attestation_message": true, "payload_attributes": true, @@ -136,6 +137,8 @@ func (e *Event) UnmarshalJSON(input []byte) error { e.Data = &FinalizedCheckpointEvent{} case "head": e.Data = &HeadEvent{} + case "head_v2": + e.Data = &HeadEventV2{} case "payload_attestation_message": e.Data = &gloas.PayloadAttestationMessage{} case "payload_attributes": diff --git a/api/v1/executionpayloadevent.go b/api/v1/executionpayloadevent.go index 31272d72..04e7a7ab 100644 --- a/api/v1/executionpayloadevent.go +++ b/api/v1/executionpayloadevent.go @@ -118,6 +118,8 @@ func (e *ExecutionPayloadEvent) String() string { // decodeFixedBytes hex-decodes a 0x-prefixed value into dst, requiring exactly // wantLen bytes. +// +//nolint:unparam // every current field is 32 bytes; wantLen keeps the helper usable for 20-byte addresses et al. func decodeFixedBytes(dst []byte, value string, wantLen int, name string) error { decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) if err != nil { diff --git a/api/v1/headeventv2.go b/api/v1/headeventv2.go new file mode 100644 index 00000000..5f28af85 --- /dev/null +++ b/api/v1/headeventv2.go @@ -0,0 +1,141 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// HeadEventV2 is the data for the head_v2 event (Gloas / EIP-7732). +// +// Relative to HeadEvent it drops previous_duty_dependent_root, renames +// current_duty_dependent_root to current_epoch_dependent_root, and adds +// next_epoch_dependent_root plus payload_status. The event can be emitted +// twice for the same block when payload_status transitions from empty to +// full. +type HeadEventV2 struct { + Slot phase0.Slot + Block phase0.Root + State phase0.Root + PayloadStatus string + EpochTransition bool + CurrentEpochDependentRoot phase0.Root + NextEpochDependentRoot phase0.Root + ExecutionOptimistic bool +} + +// headEventV2JSON is the spec representation of the struct. +type headEventV2JSON struct { + Slot string `json:"slot"` + Block string `json:"block"` + State string `json:"state"` + PayloadStatus string `json:"payload_status,omitempty"` + EpochTransition bool `json:"epoch_transition"` + CurrentEpochDependentRoot string `json:"current_epoch_dependent_root,omitempty"` + NextEpochDependentRoot string `json:"next_epoch_dependent_root,omitempty"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +// MarshalJSON implements json.Marshaler. +func (e *HeadEventV2) MarshalJSON() ([]byte, error) { + data := &headEventV2JSON{ + Slot: fmt.Sprintf("%d", e.Slot), + Block: fmt.Sprintf("%#x", e.Block), + State: fmt.Sprintf("%#x", e.State), + PayloadStatus: e.PayloadStatus, + EpochTransition: e.EpochTransition, + ExecutionOptimistic: e.ExecutionOptimistic, + } + + var zeroRoot phase0.Root + if !bytes.Equal(zeroRoot[:], e.CurrentEpochDependentRoot[:]) { + data.CurrentEpochDependentRoot = fmt.Sprintf("%#x", e.CurrentEpochDependentRoot) + } + + if !bytes.Equal(zeroRoot[:], e.NextEpochDependentRoot[:]) { + data.NextEpochDependentRoot = fmt.Sprintf("%#x", e.NextEpochDependentRoot) + } + + return json.Marshal(data) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (e *HeadEventV2) UnmarshalJSON(input []byte) error { + var headEventV2JSON headEventV2JSON + if err := json.Unmarshal(input, &headEventV2JSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if headEventV2JSON.Slot == "" { + return errors.New("slot missing") + } + + slot, err := strconv.ParseUint(headEventV2JSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid value for slot") + } + + e.Slot = phase0.Slot(slot) + + if headEventV2JSON.Block == "" { + return errors.New("block missing") + } + + if err := decodeFixedBytes(e.Block[:], headEventV2JSON.Block, rootLength, "block"); err != nil { + return err + } + + if headEventV2JSON.State == "" { + return errors.New("state missing") + } + + if err := decodeFixedBytes(e.State[:], headEventV2JSON.State, rootLength, "state"); err != nil { + return err + } + + e.PayloadStatus = headEventV2JSON.PayloadStatus + e.EpochTransition = headEventV2JSON.EpochTransition + e.ExecutionOptimistic = headEventV2JSON.ExecutionOptimistic + + // Dependent roots only have partial client coverage so do not complain if not present. + if headEventV2JSON.CurrentEpochDependentRoot != "" { + if err := decodeFixedBytes(e.CurrentEpochDependentRoot[:], headEventV2JSON.CurrentEpochDependentRoot, rootLength, "current epoch dependent root"); err != nil { + return err + } + } + + if headEventV2JSON.NextEpochDependentRoot != "" { + if err := decodeFixedBytes(e.NextEpochDependentRoot[:], headEventV2JSON.NextEpochDependentRoot, rootLength, "next epoch dependent root"); err != nil { + return err + } + } + + return nil +} + +// String returns a string version of the structure. +func (e *HeadEventV2) String() string { + data, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/headeventv2_test.go b/api/v1/headeventv2_test.go new file mode 100644 index 00000000..9c2aa78e --- /dev/null +++ b/api/v1/headeventv2_test.go @@ -0,0 +1,98 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestHeadEventV2JSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.headEventV2JSON", + }, + { + name: "SlotMissing", + input: []byte(`{"block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), + err: "slot missing", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":"-1","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + err: "invalid value for slot: strconv.ParseUint: parsing \"-1\": invalid syntax", + }, + { + name: "BlockMissing", + input: []byte(`{"slot":"525277","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + err: "block missing", + }, + { + name: "BlockWrongLength", + input: []byte(`{"slot":"525277","block":"0xe3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + err: "incorrect length 31 for block", + }, + { + name: "StateMissing", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","payload_status":"full","epoch_transition":false,"execution_optimistic":false}`), + err: "state missing", + }, + { + name: "DependentRootsMissing", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":false,"execution_optimistic":false}`), + }, + { + name: "CurrentEpochDependentRootWrongLength", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x7a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), + err: "incorrect length 31 for current epoch dependent root", + }, + { + name: "Good", + input: []byte(`{"slot":"525277","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"full","epoch_transition":false,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":false}`), + }, + { + name: "GoodEpochTransition", + input: []byte(`{"slot":"525280","block":"0x99e3f24aab3dd084045a0c927a33b8463eb5c7b17eeadfecdcf4e4badf7b6028","state":"0x749a95b1355828b758864ea601c007e69aabed7b34a0f2084c43c26242f77e28","payload_status":"empty","epoch_transition":true,"current_epoch_dependent_root":"0x907a3462a2905e3df2624869aa7f9a8635eb35bdcf9ce68a26fab691f9dada61","next_epoch_dependent_root":"0x935569bdc1aaad65dbeb532a125390d039058924ea81799238ed53e4e4639a11","execution_optimistic":true}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res api.HeadEventV2 + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + } + }) + } +} diff --git a/http/events.go b/http/events.go index 13e11bd5..878b1248 100644 --- a/http/events.go +++ b/http/events.go @@ -160,6 +160,8 @@ func (*Service) checkEventSpecificHandler(opts *api.EventsOpts, topic string) er hasHandler = opts.FinalizedCheckpointHandler != nil case "head": hasHandler = opts.HeadHandler != nil + case "head_v2": + hasHandler = opts.HeadV2Handler != nil case "payload_attestation_message": hasHandler = opts.PayloadAttestationMessageHandler != nil case "payload_attributes": @@ -229,6 +231,8 @@ func (s *Service) handleEvent(ctx context.Context, s.handleFinalizedCheckpointEvent(ctx, msg, opts) case "head": s.handleHeadEvent(ctx, msg, opts) + case "head_v2": + s.handleHeadV2Event(ctx, msg, opts) case "payload_attestation_message": s.handlePayloadAttestationMessageEvent(ctx, msg, opts) case "payload_attributes": @@ -548,6 +552,33 @@ func (*Service) handleFinalizedCheckpointEvent(ctx context.Context, } } +func (*Service) handleHeadV2Event(ctx context.Context, + msg *sse.Event, + opts *api.EventsOpts, +) { + log := zerolog.Ctx(ctx) + data := &apiv1.HeadEventV2{} + + err := unmarshalVersionedEventData(msg.Data, data) + if err != nil { + log.Error().Err(err).RawJSON("data", msg.Data).Msg("Failed to parse head_v2 event") + + return + } + + switch { + case opts.HeadV2Handler != nil: + opts.HeadV2Handler(ctx, data) + case opts.Handler != nil: + opts.Handler(&apiv1.Event{ + Topic: string(msg.Event), + Data: data, + }) + default: + log.Debug().Msg("No specific or generic handler supplied; ignoring") + } +} + func (*Service) handleHeadEvent(ctx context.Context, msg *sse.Event, opts *api.EventsOpts, diff --git a/spec/gloas/beaconstate_json.go b/spec/gloas/beaconstate_json.go index b6461ec6..8ad86fc3 100644 --- a/spec/gloas/beaconstate_json.go +++ b/spec/gloas/beaconstate_json.go @@ -72,7 +72,7 @@ type beaconStateJSON struct { ProposerLookahead []string `json:"proposer_lookahead"` Builders []*Builder `json:"builders"` NextWithdrawalBuilderIndex string `json:"next_withdrawal_builder_index"` - ExecutionPayloadAvailability []string `json:"execution_payload_availability"` + ExecutionPayloadAvailability string `json:"execution_payload_availability"` BuilderPendingPayments []*BuilderPendingPayment `json:"builder_pending_payments"` BuilderPendingWithdrawals []*BuilderPendingWithdrawal `json:"builder_pending_withdrawals"` LatestExecutionPayloadBid *ExecutionPayloadBid `json:"latest_execution_payload_bid"` @@ -110,10 +110,6 @@ func (b *BeaconState) MarshalJSON() ([]byte, error) { for i := range b.ProposerLookahead { proposerLookahead[i] = fmt.Sprintf("%d", b.ProposerLookahead[i]) } - executionPayloadAvailability := make([]string, len(b.ExecutionPayloadAvailability)) - for i := range b.ExecutionPayloadAvailability { - executionPayloadAvailability[i] = fmt.Sprintf("%d", b.ExecutionPayloadAvailability[i]) - } ptcWindow := make([][]string, len(b.PTCWindow)) for i := range b.PTCWindow { ptcWindow[i] = make([]string, len(b.PTCWindow[i])) @@ -163,7 +159,7 @@ func (b *BeaconState) MarshalJSON() ([]byte, error) { ProposerLookahead: proposerLookahead, Builders: b.Builders, NextWithdrawalBuilderIndex: fmt.Sprintf("%d", b.NextWithdrawalBuilderIndex), - ExecutionPayloadAvailability: executionPayloadAvailability, + ExecutionPayloadAvailability: fmt.Sprintf("%#x", b.ExecutionPayloadAvailability), BuilderPendingPayments: b.BuilderPendingPayments, BuilderPendingWithdrawals: b.BuilderPendingWithdrawals, LatestExecutionPayloadBid: b.LatestExecutionPayloadBid, @@ -405,7 +401,8 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { return errors.Wrap(err, "next_withdrawal_builder_index") } - if err := json.Unmarshal(raw["execution_payload_availability"], &b.ExecutionPayloadAvailability); err != nil { + executionPayloadAvailability := string(bytes.TrimPrefix(bytes.Trim(raw["execution_payload_availability"], `"`), []byte{'0', 'x'})) + if b.ExecutionPayloadAvailability, err = hex.DecodeString(executionPayloadAvailability); err != nil { return errors.Wrap(err, "execution_payload_availability") } @@ -441,15 +438,17 @@ func (b *BeaconState) UnmarshalJSON(input []byte) error { } } - ptcWindowStr := make([][]string, 0) - if err := json.Unmarshal(raw["ptc_window"], &ptcWindowStr); err != nil { + // The beacon API convention quotes uint64 values, but lighthouse serves the + // ptc_window indices as bare JSON numbers — accept either form. + ptcWindowRaw := make([][]json.RawMessage, 0) + if err := json.Unmarshal(raw["ptc_window"], &ptcWindowRaw); err != nil { return errors.Wrap(err, "ptc_window") } - b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowStr)) - for i := range ptcWindowStr { - b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowStr[i])) - for j := range ptcWindowStr[i] { - idx, parseErr := strconv.ParseUint(ptcWindowStr[i][j], 10, 64) + b.PTCWindow = make([][]phase0.ValidatorIndex, len(ptcWindowRaw)) + for i := range ptcWindowRaw { + b.PTCWindow[i] = make([]phase0.ValidatorIndex, len(ptcWindowRaw[i])) + for j := range ptcWindowRaw[i] { + idx, parseErr := strconv.ParseUint(string(bytes.Trim(ptcWindowRaw[i][j], `"`)), 10, 64) if parseErr != nil { return errors.Wrap(parseErr, fmt.Sprintf("ptc_window[%d][%d]", i, j)) } diff --git a/spec/gloas/beaconstate_test.go b/spec/gloas/beaconstate_test.go new file mode 100644 index 00000000..abbfd297 --- /dev/null +++ b/spec/gloas/beaconstate_test.go @@ -0,0 +1,205 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "strings" + "testing" + + bitfield "github.com/OffchainLabs/go-bitfield" + "github.com/ethpandaops/go-eth2-client/spec/altair" + "github.com/ethpandaops/go-eth2-client/spec/capella" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// testBeaconState builds a small but fully-populated state so the JSON +// round-trip exercises every field's encoder and decoder. +func testBeaconState() *gloas.BeaconState { + var pubkey phase0.BLSPubKey + pubkey[0] = 0xaa + + syncCommittee := &altair.SyncCommittee{ + Pubkeys: []phase0.BLSPubKey{pubkey}, + AggregatePubkey: pubkey, + } + + return &gloas.BeaconState{ + GenesisTime: 1784030400, + GenesisValidatorsRoot: phase0.Root{0x01}, + Slot: 25736, + Fork: &phase0.Fork{ + PreviousVersion: phase0.Version{0x70, 0x66, 0x95, 0x68}, + CurrentVersion: phase0.Version{0x80, 0x66, 0x95, 0x68}, + Epoch: 38, + }, + LatestBlockHeader: &phase0.BeaconBlockHeader{ + Slot: 25735, + ProposerIndex: 3, + ParentRoot: phase0.Root{0x02}, + StateRoot: phase0.Root{0x03}, + BodyRoot: phase0.Root{0x04}, + }, + BlockRoots: []phase0.Root{{0x05}}, + StateRoots: []phase0.Root{{0x06}}, + HistoricalRoots: []phase0.Root{{0x07}}, + ETH1Data: &phase0.ETH1Data{ + DepositRoot: phase0.Root{0x08}, + DepositCount: 1, + BlockHash: make([]byte, 32), + }, + ETH1DataVotes: []*phase0.ETH1Data{}, + ETH1DepositIndex: 1, + Validators: []*phase0.Validator{ + { + PublicKey: pubkey, + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 32000000000, + ActivationEligibilityEpoch: 0, + ActivationEpoch: 0, + ExitEpoch: phase0.Epoch(0xffffffffffffffff), + WithdrawableEpoch: phase0.Epoch(0xffffffffffffffff), + }, + }, + Balances: []phase0.Gwei{32000000000}, + RANDAOMixes: []phase0.Root{{0x09}}, + Slashings: []phase0.Gwei{0}, + PreviousEpochParticipation: []altair.ParticipationFlags{7}, + CurrentEpochParticipation: []altair.ParticipationFlags{3}, + JustificationBits: bitfield.Bitvector4{0x0f}, + PreviousJustifiedCheckpoint: &phase0.Checkpoint{Epoch: 802, Root: phase0.Root{0x0a}}, + CurrentJustifiedCheckpoint: &phase0.Checkpoint{Epoch: 803, Root: phase0.Root{0x0b}}, + FinalizedCheckpoint: &phase0.Checkpoint{Epoch: 802, Root: phase0.Root{0x0c}}, + InactivityScores: []uint64{0}, + CurrentSyncCommittee: syncCommittee, + NextSyncCommittee: syncCommittee, + LatestBlockHash: phase0.Hash32{0x0d}, + NextWithdrawalIndex: 15638, + NextWithdrawalValidatorIndex: 470013, + HistoricalSummaries: []*capella.HistoricalSummary{}, + DepositRequestsStartIndex: 0, + DepositBalanceToConsume: 0, + ExitBalanceToConsume: 256000000000, + EarliestExitEpoch: 804, + ConsolidationBalanceToConsume: 0, + EarliestConsolidationEpoch: 805, + ProposerLookahead: []phase0.ValidatorIndex{1039, 503062}, + Builders: []*gloas.Builder{ + { + PublicKey: pubkey, + Version: 1, + Balance: 32000000000, + DepositEpoch: 12, + WithdrawableEpoch: 18, + }, + }, + NextWithdrawalBuilderIndex: 0, + // A bitvector: one bit per historical slot, hex-encoded on the wire. + ExecutionPayloadAvailability: []uint8{0xff, 0x7f, 0x00, 0xbf}, + BuilderPendingPayments: []*gloas.BuilderPendingPayment{ + { + Weight: 0, + Withdrawal: &gloas.BuilderPendingWithdrawal{ + Amount: 0, + BuilderIndex: 0, + }, + ProposerIndex: 0, + }, + }, + BuilderPendingWithdrawals: []*gloas.BuilderPendingWithdrawal{}, + LatestExecutionPayloadBid: &gloas.ExecutionPayloadBid{ + ParentBlockHash: phase0.Hash32{0x0e}, + ParentBlockRoot: phase0.Root{0x0f}, + BlockHash: phase0.Hash32{0x10}, + PrevRandao: phase0.Root{0x11}, + GasLimit: 60000000, + BuilderIndex: 0, + Slot: 25736, + Value: 0, + ExecutionPayment: 0, + BlobKZGCommitments: []deneb.KZGCommitment{}, + }, + PayloadExpectedWithdrawals: []*capella.Withdrawal{ + { + Index: 15637, + ValidatorIndex: 1099511627778, + Amount: 128018656, + }, + }, + PTCWindow: [][]phase0.ValidatorIndex{ + {1261, 1969, 503057}, + {323, 2478}, + }, + } +} + +func TestBeaconStateJSONRoundTrip(t *testing.T) { + state := testBeaconState() + + encoded, err := json.Marshal(state) + require.NoError(t, err) + + // Bitvectors are hex strings on the wire, not per-byte arrays. + require.Contains(t, string(encoded), `"execution_payload_availability":"0xff7f00bf"`) + // uint64 values are quoted per the beacon API convention. + require.Contains(t, string(encoded), `"ptc_window":[["1261","1969","503057"],["323","2478"]]`) + + decoded := &gloas.BeaconState{} + require.NoError(t, json.Unmarshal(encoded, decoded)) + require.Equal(t, state.ExecutionPayloadAvailability, decoded.ExecutionPayloadAvailability) + require.Equal(t, state.PTCWindow, decoded.PTCWindow) + require.Equal(t, state.Builders, decoded.Builders) +} + +// TestBeaconStateJSONLighthouse checks the encodings lighthouse actually +// serves where they diverge from the quoted-integer convention. +func TestBeaconStateJSONLighthouse(t *testing.T) { + state := testBeaconState() + + encoded, err := json.Marshal(state) + require.NoError(t, err) + + // Lighthouse serves ptc_window indices as bare JSON numbers. + lighthouse := strings.Replace( + string(encoded), + `"ptc_window":[["1261","1969","503057"],["323","2478"]]`, + `"ptc_window":[[1261,1969,503057],[323,2478]]`, + 1, + ) + require.NotEqual(t, string(encoded), lighthouse) + + decoded := &gloas.BeaconState{} + require.NoError(t, json.Unmarshal([]byte(lighthouse), decoded)) + require.Equal(t, state.PTCWindow, decoded.PTCWindow) +} + +func TestBeaconStateJSONInvalidAvailability(t *testing.T) { + state := testBeaconState() + + encoded, err := json.Marshal(state) + require.NoError(t, err) + + invalid := strings.Replace( + string(encoded), + `"execution_payload_availability":"0xff7f00bf"`, + `"execution_payload_availability":"0xzz"`, + 1, + ) + + decoded := &gloas.BeaconState{} + require.ErrorContains(t, json.Unmarshal([]byte(invalid), decoded), "execution_payload_availability") +} diff --git a/spec/gloas/builder.go b/spec/gloas/builder.go index bb9f67b8..aced7964 100644 --- a/spec/gloas/builder.go +++ b/spec/gloas/builder.go @@ -40,7 +40,7 @@ type Builder struct { // builderJSON is the spec representation of the struct. type builderJSON struct { PublicKey string `json:"pubkey"` - Version uint8 `json:"version"` + Version string `json:"version"` ExecutionAddress string `json:"execution_address"` Balance string `json:"balance"` DepositEpoch string `json:"deposit_epoch"` @@ -50,7 +50,7 @@ type builderJSON struct { // builderYAML is the spec representation of the struct. type builderYAML struct { PublicKey string `yaml:"pubkey"` - Version uint8 `yaml:"version"` + Version string `yaml:"version"` ExecutionAddress string `yaml:"execution_address"` Balance string `yaml:"balance"` DepositEpoch string `yaml:"deposit_epoch"` @@ -61,7 +61,7 @@ type builderYAML struct { func (v *Builder) MarshalJSON() ([]byte, error) { return json.Marshal(&builderJSON{ PublicKey: fmt.Sprintf("%#x", v.PublicKey), - Version: v.Version, + Version: fmt.Sprintf("%d", v.Version), ExecutionAddress: v.ExecutionAddress.String(), Balance: fmt.Sprintf("%d", v.Balance), DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), @@ -95,6 +95,17 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { copy(v.PublicKey[:], publicKey) + if builderJSON.Version == "" { + return errors.New("version missing") + } + + version, err := strconv.ParseUint(builderJSON.Version, 10, 8) + if err != nil { + return errors.Wrap(err, "invalid value for version") + } + + v.Version = uint8(version) + if builderJSON.ExecutionAddress == "" { return errors.New("execution address missing") } @@ -154,6 +165,7 @@ func (v *Builder) unpack(builderJSON *builderJSON) error { func (v *Builder) MarshalYAML() ([]byte, error) { yamlBytes, err := yaml.MarshalWithOptions(&builderYAML{ PublicKey: fmt.Sprintf("%#x", v.PublicKey), + Version: fmt.Sprintf("%d", v.Version), ExecutionAddress: v.ExecutionAddress.String(), Balance: fmt.Sprintf("%d", v.Balance), DepositEpoch: fmt.Sprintf("%d", v.DepositEpoch), diff --git a/spec/gloas/builder_test.go b/spec/gloas/builder_test.go new file mode 100644 index 00000000..ce3ebcf0 --- /dev/null +++ b/spec/gloas/builder_test.go @@ -0,0 +1,83 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gloas_test + +import ( + "encoding/json" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/stretchr/testify/require" +) + +func TestBuilderJSON(t *testing.T) { + // Beacon API JSON encodes all integers as strings. + input := `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"1","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"18446744073709551615"}` + + var builder gloas.Builder + require.NoError(t, json.Unmarshal([]byte(input), &builder)) + require.Equal(t, uint8(1), builder.Version) + + // Round-trip. + output, err := json.Marshal(&builder) + require.NoError(t, err) + require.JSONEq(t, input, string(output)) +} + +func TestBuilderJSONInvalidVersion(t *testing.T) { + tests := []struct { + name string + input string + errorMsg string + }{ + { + name: "missing", + input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, + errorMsg: "version missing", + }, + { + name: "not a number", + input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"banana","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, + errorMsg: "invalid value for version", + }, + { + name: "out of range for uint8", + input: `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"256","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}`, + errorMsg: "invalid value for version", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var builder gloas.Builder + err := json.Unmarshal([]byte(test.input), &builder) + require.ErrorContains(t, err, test.errorMsg) + }) + } +} + +func TestBuilderYAML(t *testing.T) { + input := `{"pubkey":"0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c","version":"1","execution_address":"0x000102030405060708090a0b0c0d0e0f10111213","balance":"32000000000","deposit_epoch":"12","withdrawable_epoch":"13"}` + + var builder gloas.Builder + require.NoError(t, json.Unmarshal([]byte(input), &builder)) + + yamlBytes, err := builder.MarshalYAML() + require.NoError(t, err) + require.Contains(t, string(yamlBytes), "version: '1'") + + var roundTripped gloas.Builder + require.NoError(t, roundTripped.UnmarshalYAML(yamlBytes)) + require.Equal(t, builder, roundTripped) +}