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") +}