From 5f909aa1d19b013f9ef98bdc724a807c5f9d4621 Mon Sep 17 00:00:00 2001 From: jonny rhea <5555162+jrhea@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:27:20 -0500 Subject: [PATCH] beacon/engin: add unmarshalJSON for epe and bapl structs --- .../{marshal_bap_list.go => codec_bapl.go} | 72 ++++++++ ...al_bap_list_test.go => codec_bapl_test.go} | 102 +++++++++++ .../engine/{marshal_epe.go => codec_epe.go} | 125 +++++++++++--- ...{marshal_epe_test.go => codec_epe_test.go} | 94 +++++++++++ beacon/engine/codec_helper.go | 158 ++++++++++++++++++ beacon/engine/gen_epe.go | 53 ------ beacon/engine/types.go | 6 - 7 files changed, 526 insertions(+), 84 deletions(-) rename beacon/engine/{marshal_bap_list.go => codec_bapl.go} (65%) rename beacon/engine/{marshal_bap_list_test.go => codec_bapl_test.go} (70%) rename beacon/engine/{marshal_epe.go => codec_epe.go} (61%) rename beacon/engine/{marshal_epe_test.go => codec_epe_test.go} (76%) create mode 100644 beacon/engine/codec_helper.go delete mode 100644 beacon/engine/gen_epe.go diff --git a/beacon/engine/marshal_bap_list.go b/beacon/engine/codec_bapl.go similarity index 65% rename from beacon/engine/marshal_bap_list.go rename to beacon/engine/codec_bapl.go index 57294137ac..d07882710c 100644 --- a/beacon/engine/marshal_bap_list.go +++ b/beacon/engine/codec_bapl.go @@ -16,6 +16,8 @@ package engine +import "encoding/json" + // estimateBlobAndProofV1Size returns a rough estimate of the JSON size for a BlobAndProofV1. func estimateBlobAndProofV1Size(item *BlobAndProofV1) int { if item == nil { @@ -87,6 +89,39 @@ func (list BlobAndProofListV1) MarshalJSON() ([]byte, error) { return buf, nil } +// UnmarshalJSON implements json.Unmarshaler. +func (list *BlobAndProofListV1) UnmarshalJSON(input []byte) error { + if isJSONNull(input) { + *list = nil + return nil + } + items := make(BlobAndProofListV1, 0) + if err := decodeJSONArray(input, func(value json.RawMessage) error { + if isJSONNull(value) { + items = append(items, nil) + return nil + } + item := new(BlobAndProofV1) + if err := decodeJSONObject(value, func(key string, value json.RawMessage) error { + switch key { + case "blob": + return item.Blob.UnmarshalJSON(value) + case "proof": + return item.Proof.UnmarshalJSON(value) + } + return nil + }); err != nil { + return err + } + items = append(items, item) + return nil + }); err != nil { + return err + } + *list = items + return nil +} + // MarshalJSON implements json.Marshaler. func (list BlobAndProofListV2) MarshalJSON() ([]byte, error) { // Estimate buffer size. @@ -107,3 +142,40 @@ func (list BlobAndProofListV2) MarshalJSON() ([]byte, error) { buf = append(buf, ']') return buf, nil } + +// UnmarshalJSON implements json.Unmarshaler. +func (list *BlobAndProofListV2) UnmarshalJSON(input []byte) error { + if isJSONNull(input) { + *list = nil + return nil + } + items := make(BlobAndProofListV2, 0) + if err := decodeJSONArray(input, func(value json.RawMessage) error { + if isJSONNull(value) { + items = append(items, nil) + return nil + } + item := new(BlobAndProofV2) + if err := decodeJSONObject(value, func(key string, value json.RawMessage) error { + switch key { + case "blob": + return item.Blob.UnmarshalJSON(value) + case "proofs": + proofs, err := unmarshalHexBytesArray(value) + if err != nil { + return err + } + item.CellProofs = proofs + } + return nil + }); err != nil { + return err + } + items = append(items, item) + return nil + }); err != nil { + return err + } + *list = items + return nil +} diff --git a/beacon/engine/marshal_bap_list_test.go b/beacon/engine/codec_bapl_test.go similarity index 70% rename from beacon/engine/marshal_bap_list_test.go rename to beacon/engine/codec_bapl_test.go index 48d3cc65bb..6a94c4d530 100644 --- a/beacon/engine/marshal_bap_list_test.go +++ b/beacon/engine/codec_bapl_test.go @@ -122,6 +122,57 @@ func TestBlobAndProofListV1MarshalJSON(t *testing.T) { } } +func TestBlobAndProofListV1UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want BlobAndProofListV1 + }{ + { + name: "multiple items", + json: `[{"blob":"0x0102","proof":"0x0304"},{"blob":"0x","proof":"0x05"}]`, + want: BlobAndProofListV1{ + { + Blob: hexutil.Bytes{0x01, 0x02}, + Proof: hexutil.Bytes{0x03, 0x04}, + }, + { + Blob: hexutil.Bytes{}, + Proof: hexutil.Bytes{0x05}, + }, + }, + }, + { + name: "nil item", + json: `[null,{"blob":"0xaa","proof":"0xbb"}]`, + want: BlobAndProofListV1{ + nil, + { + Blob: hexutil.Bytes{0xaa}, + Proof: hexutil.Bytes{0xbb}, + }, + }, + }, + { + name: "null list", + json: `null`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got BlobAndProofListV1 + if err := got.UnmarshalJSON([]byte(tt.json)); err != nil { + t.Fatalf("UnmarshalJSON error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("decoded mismatch\ngot: %#v\nwant: %#v", got, tt.want) + } + }) + } +} + func TestBlobAndProofListV2MarshalJSON(t *testing.T) { tests := []struct { name string @@ -186,6 +237,57 @@ func TestBlobAndProofListV2MarshalJSON(t *testing.T) { } } +func TestBlobAndProofListV2UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want BlobAndProofListV2 + }{ + { + name: "multiple items", + json: `[{"blob":"0x0102","proofs":["0x0304","0x05"]},{"blob":"0x","proofs":[]}]`, + want: BlobAndProofListV2{ + { + Blob: hexutil.Bytes{0x01, 0x02}, + CellProofs: []hexutil.Bytes{{0x03, 0x04}, {0x05}}, + }, + { + Blob: hexutil.Bytes{}, + CellProofs: []hexutil.Bytes{}, + }, + }, + }, + { + name: "nil item and nil proofs", + json: `[null,{"blob":"0xcc","proofs":null}]`, + want: BlobAndProofListV2{ + nil, + { + Blob: hexutil.Bytes{0xcc}, + CellProofs: nil, + }, + }, + }, + { + name: "null list", + json: `null`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got BlobAndProofListV2 + if err := got.UnmarshalJSON([]byte(tt.json)); err != nil { + t.Fatalf("UnmarshalJSON error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("decoded mismatch\ngot: %#v\nwant: %#v", got, tt.want) + } + }) + } +} + func TestBlobAndProofFieldCoverage(t *testing.T) { tests := []struct { name string diff --git a/beacon/engine/marshal_epe.go b/beacon/engine/codec_epe.go similarity index 61% rename from beacon/engine/marshal_epe.go rename to beacon/engine/codec_epe.go index 849df76da1..985a9485a9 100644 --- a/beacon/engine/marshal_epe.go +++ b/beacon/engine/codec_epe.go @@ -17,10 +17,9 @@ package engine import ( - "encoding/hex" "encoding/json" "errors" - "slices" + "math/big" "github.com/ethereum/go-ethereum/common/hexutil" ) @@ -55,32 +54,26 @@ func marshalBlobsBundle(buf []byte, b *BlobsBundle) []byte { return buf } -// marshalHexBytesArray writes an array of hex-encoded byte slices to buf. -// A nil slice is written as "null" to match encoding/json semantics. -func marshalHexBytesArray(buf []byte, items []hexutil.Bytes) []byte { - if items == nil { - return append(buf, "null"...) +func unmarshalBlobsBundle(input []byte) (*BlobsBundle, error) { + if isJSONNull(input) { + return nil, nil } - buf = append(buf, '[') - for i, item := range items { - if i > 0 { - buf = append(buf, ',') + var bundle BlobsBundle + if err := decodeJSONObject(input, func(key string, value json.RawMessage) error { + var err error + switch key { + case "commitments": + bundle.Commitments, err = unmarshalHexBytesArray(value) + case "proofs": + bundle.Proofs, err = unmarshalHexBytesArray(value) + case "blobs": + bundle.Blobs, err = unmarshalHexBytesArray(value) } - buf = writeHexBytes(buf, item) + return err + }); err != nil { + return nil, err } - buf = append(buf, ']') - return buf -} - -// writeHexBytes writes a hex-encoded byte slice as a JSON string ("0x...") to buf. -func writeHexBytes(buf []byte, data []byte) []byte { - buf = append(buf, '"', '0', 'x') - buf = slices.Grow(buf, len(data)*2+1) - cur := len(buf) - buf = buf[:cur+len(data)*2] - hex.Encode(buf[cur:], data) - buf = append(buf, '"') - return buf + return &bundle, nil } // MarshalJSON implements json.Marshaler. @@ -175,3 +168,85 @@ func (e ExecutionPayloadEnvelope) MarshalJSON() ([]byte, error) { buf = append(buf, '}') return buf, nil } + +// UnmarshalJSON implements json.Unmarshaler. +func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { + var ( + payloadSeen bool + blockValueSeen bool + ) + *e = ExecutionPayloadEnvelope{} + if err := decodeJSONObject(input, func(key string, value json.RawMessage) error { + switch key { + case "executionPayload": + payloadSeen = true + if isJSONNull(value) { + e.ExecutionPayload = nil + return nil + } + var payload ExecutableData + if err := payload.UnmarshalJSON(value); err != nil { + return err + } + e.ExecutionPayload = &payload + case "blockValue": + blockValueSeen = true + if isJSONNull(value) { + e.BlockValue = nil + return nil + } + var blockValue hexutil.Big + if err := blockValue.UnmarshalJSON(value); err != nil { + return err + } + e.BlockValue = (*big.Int)(&blockValue) + case "blobsBundle": + bundle, err := unmarshalBlobsBundle(value) + if err != nil { + return err + } + e.BlobsBundle = bundle + case "executionRequests": + requests, err := unmarshalHexBytesArray(value) + if err != nil { + return err + } + if requests == nil { + e.Requests = nil + return nil + } + e.Requests = make([][]byte, len(requests)) + for i, req := range requests { + e.Requests[i] = req + } + case "shouldOverrideBuilder": + if isJSONNull(value) { + e.Override = false + return nil + } + if err := json.Unmarshal(value, &e.Override); err != nil { + return err + } + case "witness": + if isJSONNull(value) { + e.Witness = nil + return nil + } + var witness hexutil.Bytes + if err := witness.UnmarshalJSON(value); err != nil { + return err + } + e.Witness = &witness + } + return nil + }); err != nil { + return err + } + if !payloadSeen || e.ExecutionPayload == nil { + return errors.New("missing required field 'executionPayload' for ExecutionPayloadEnvelope") + } + if !blockValueSeen || e.BlockValue == nil { + return errors.New("missing required field 'blockValue' for ExecutionPayloadEnvelope") + } + return nil +} diff --git a/beacon/engine/marshal_epe_test.go b/beacon/engine/codec_epe_test.go similarity index 76% rename from beacon/engine/marshal_epe_test.go rename to beacon/engine/codec_epe_test.go index 7b5532167c..aa36d39bce 100644 --- a/beacon/engine/marshal_epe_test.go +++ b/beacon/engine/codec_epe_test.go @@ -214,6 +214,100 @@ func TestMarshalJSONRoundtrip(t *testing.T) { } } +func TestUnmarshalJSON(t *testing.T) { + witness := hexutil.Bytes{0xde, 0xad} + tests := []struct { + name string + env ExecutionPayloadEnvelope + }{ + { + name: "full envelope with blobs", + env: ExecutionPayloadEnvelope{ + ExecutionPayload: makeTestPayload(), + BlockValue: big.NewInt(12345), + BlobsBundle: &BlobsBundle{ + Commitments: []hexutil.Bytes{{0x01, 0x02}}, + Proofs: []hexutil.Bytes{{0x03, 0x04}}, + Blobs: []hexutil.Bytes{{0x05, 0x06}}, + }, + Requests: [][]byte{{0xaa}, {0xbb, 0xcc}}, + Override: true, + Witness: &witness, + }, + }, + { + name: "null optional fields", + env: ExecutionPayloadEnvelope{ + ExecutionPayload: makeTestPayload(), + BlockValue: big.NewInt(1), + BlobsBundle: nil, + Requests: nil, + Override: false, + Witness: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, err := json.Marshal(toCanonical(&tt.env)) + if err != nil { + t.Fatalf("canonical marshal error: %v", err) + } + + var got ExecutionPayloadEnvelope + if err := got.UnmarshalJSON(input); err != nil { + t.Fatalf("UnmarshalJSON error: %v", err) + } + + gotJSON, err := json.Marshal(toCanonical(&got)) + if err != nil { + t.Fatalf("canonical marshal after unmarshal error: %v", err) + } + if !bytes.Equal(compactJSON(gotJSON), compactJSON(input)) { + t.Errorf("JSON mismatch after unmarshal\ngot: %s\nwant: %s", gotJSON, input) + } + }) + } +} + +func TestUnmarshalJSONMissingRequiredFields(t *testing.T) { + tests := []struct { + name string + json func(t *testing.T) []byte + }{ + { + name: "missing executionPayload", + json: func(t *testing.T) []byte { + return []byte(`{"blockValue":"0x1"}`) + }, + }, + { + name: "missing blockValue", + json: func(t *testing.T) []byte { + input, err := json.Marshal(struct { + ExecutionPayload *ExecutableData `json:"executionPayload"` + }{ + ExecutionPayload: makeTestPayload(), + }) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + return input + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var env ExecutionPayloadEnvelope + if err := env.UnmarshalJSON(tt.json(t)); err == nil { + t.Fatal("expected error") + } + }) + } +} + func TestMarshalJSONNilPayload(t *testing.T) { env := ExecutionPayloadEnvelope{ ExecutionPayload: nil, diff --git a/beacon/engine/codec_helper.go b/beacon/engine/codec_helper.go new file mode 100644 index 0000000000..5672cd3f74 --- /dev/null +++ b/beacon/engine/codec_helper.go @@ -0,0 +1,158 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package engine + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "slices" + + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// marshalHexBytesArray writes an array of hex-encoded byte slices to buf. +// A nil slice is written as "null" to match encoding/json semantics. +func marshalHexBytesArray(buf []byte, items []hexutil.Bytes) []byte { + if items == nil { + return append(buf, "null"...) + } + buf = append(buf, '[') + for i, item := range items { + if i > 0 { + buf = append(buf, ',') + } + buf = writeHexBytes(buf, item) + } + buf = append(buf, ']') + return buf +} + +// writeHexBytes writes a hex-encoded byte slice as a JSON string ("0x...") to buf. +func writeHexBytes(buf []byte, data []byte) []byte { + buf = append(buf, '"', '0', 'x') + buf = slices.Grow(buf, len(data)*2+1) + cur := len(buf) + buf = buf[:cur+len(data)*2] + hex.Encode(buf[cur:], data) + buf = append(buf, '"') + return buf +} + +func decodeJSONObject(input []byte, fn func(key string, value json.RawMessage) error) error { + dec := json.NewDecoder(bytes.NewReader(input)) + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + return fmt.Errorf("expected JSON object") + } + for dec.More() { + tok, err := dec.Token() + if err != nil { + return err + } + key, ok := tok.(string) + if !ok { + return fmt.Errorf("expected JSON object key") + } + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return err + } + if err := fn(key, value); err != nil { + return err + } + } + tok, err = dec.Token() + if err != nil { + return err + } + delim, ok = tok.(json.Delim) + if !ok || delim != '}' { + return fmt.Errorf("expected end of JSON object") + } + if _, err := dec.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing data") + } + return err + } + return nil +} + +func decodeJSONArray(input []byte, fn func(value json.RawMessage) error) error { + dec := json.NewDecoder(bytes.NewReader(input)) + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok || delim != '[' { + return fmt.Errorf("expected JSON array") + } + for dec.More() { + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return err + } + if err := fn(value); err != nil { + return err + } + } + tok, err = dec.Token() + if err != nil { + return err + } + delim, ok = tok.(json.Delim) + if !ok || delim != ']' { + return fmt.Errorf("expected end of JSON array") + } + if _, err := dec.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing data") + } + return err + } + return nil +} + +func isJSONNull(input []byte) bool { + return bytes.Equal(bytes.TrimSpace(input), []byte("null")) +} + +func unmarshalHexBytesArray(input []byte) ([]hexutil.Bytes, error) { + if isJSONNull(input) { + return nil, nil + } + items := make([]hexutil.Bytes, 0) + if err := decodeJSONArray(input, func(value json.RawMessage) error { + var item hexutil.Bytes + if err := item.UnmarshalJSON(value); err != nil { + return err + } + items = append(items, item) + return nil + }); err != nil { + return nil, err + } + return items, nil +} diff --git a/beacon/engine/gen_epe.go b/beacon/engine/gen_epe.go deleted file mode 100644 index a125daa030..0000000000 --- a/beacon/engine/gen_epe.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by github.com/fjl/gencodec. DO NOT EDIT. - -package engine - -import ( - "encoding/json" - "errors" - "math/big" - - "github.com/ethereum/go-ethereum/common/hexutil" -) - -var _ = (*executionPayloadEnvelopeMarshaling)(nil) - -// UnmarshalJSON unmarshals from JSON. -func (e *ExecutionPayloadEnvelope) UnmarshalJSON(input []byte) error { - type ExecutionPayloadEnvelope struct { - ExecutionPayload *ExecutableData `json:"executionPayload" gencodec:"required"` - BlockValue *hexutil.Big `json:"blockValue" gencodec:"required"` - BlobsBundle *BlobsBundle `json:"blobsBundle"` - Requests []hexutil.Bytes `json:"executionRequests"` - Override *bool `json:"shouldOverrideBuilder"` - Witness *hexutil.Bytes `json:"witness,omitempty"` - } - var dec ExecutionPayloadEnvelope - if err := json.Unmarshal(input, &dec); err != nil { - return err - } - if dec.ExecutionPayload == nil { - return errors.New("missing required field 'executionPayload' for ExecutionPayloadEnvelope") - } - e.ExecutionPayload = dec.ExecutionPayload - if dec.BlockValue == nil { - return errors.New("missing required field 'blockValue' for ExecutionPayloadEnvelope") - } - e.BlockValue = (*big.Int)(dec.BlockValue) - if dec.BlobsBundle != nil { - e.BlobsBundle = dec.BlobsBundle - } - if dec.Requests != nil { - e.Requests = make([][]byte, len(dec.Requests)) - for k, v := range dec.Requests { - e.Requests[k] = v - } - } - if dec.Override != nil { - e.Override = *dec.Override - } - if dec.Witness != nil { - e.Witness = dec.Witness - } - return nil -} diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 92cf3900ae..cfd0dba395 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -165,12 +165,6 @@ type BlobAndProofV2 struct { // that avoids the overhead of encoding/json for large blob payloads. type BlobAndProofListV2 []*BlobAndProofV2 -// JSON type overrides for ExecutionPayloadEnvelope. -type executionPayloadEnvelopeMarshaling struct { - BlockValue *hexutil.Big - Requests []hexutil.Bytes -} - type PayloadStatusV1 struct { Status string `json:"status"` Witness *hexutil.Bytes `json:"witness,omitempty"`