diff --git a/beacon/engine/marshal_bap_list.go b/beacon/engine/marshal_bap_list.go new file mode 100644 index 0000000000..57294137ac --- /dev/null +++ b/beacon/engine/marshal_bap_list.go @@ -0,0 +1,109 @@ +// 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 + +// estimateBlobAndProofV1Size returns a rough estimate of the JSON size for a BlobAndProofV1. +func estimateBlobAndProofV1Size(item *BlobAndProofV1) int { + if item == nil { + return 4 + } + return len(item.Blob)*2 + len(item.Proof)*2 + 30 +} + +// marshalBlobAndProofV1 writes a BlobAndProofV1 as JSON and appends it to buf. +func marshalBlobAndProofV1(buf []byte, item *BlobAndProofV1) []byte { + if item == nil { + return append(buf, "null"...) + } + buf = append(buf, `{"blob":`...) + buf = writeHexBytes(buf, item.Blob) + + buf = append(buf, `,"proof":`...) + buf = writeHexBytes(buf, item.Proof) + + buf = append(buf, '}') + return buf +} + +// estimateBlobAndProofV2Size returns a rough estimate of the JSON size for a BlobAndProofV2. +func estimateBlobAndProofV2Size(item *BlobAndProofV2) int { + if item == nil { + return 4 + } + size := len(item.Blob)*2 + 30 + for _, proof := range item.CellProofs { + size += len(proof)*2 + 6 + } + return size +} + +// marshalBlobAndProofV2 writes a BlobAndProofV2 as JSON and appends it to buf. +func marshalBlobAndProofV2(buf []byte, item *BlobAndProofV2) []byte { + if item == nil { + return append(buf, "null"...) + } + buf = append(buf, `{"blob":`...) + buf = writeHexBytes(buf, item.Blob) + + buf = append(buf, `,"proofs":`...) + buf = marshalHexBytesArray(buf, item.CellProofs) + + buf = append(buf, '}') + return buf +} + +// MarshalJSON implements json.Marshaler. +func (list BlobAndProofListV1) MarshalJSON() ([]byte, error) { + // Estimate buffer size. + size := 2 + for _, item := range list { + size += estimateBlobAndProofV1Size(item) + 1 + } + buf := make([]byte, 0, size) + + // Write the array elements to the buffer. + buf = append(buf, '[') + for i, item := range list { + if i > 0 { + buf = append(buf, ',') + } + buf = marshalBlobAndProofV1(buf, item) + } + buf = append(buf, ']') + return buf, nil +} + +// MarshalJSON implements json.Marshaler. +func (list BlobAndProofListV2) MarshalJSON() ([]byte, error) { + // Estimate buffer size. + size := 2 + for _, item := range list { + size += estimateBlobAndProofV2Size(item) + 1 + } + buf := make([]byte, 0, size) + + // Write the array elements to the buffer. + buf = append(buf, '[') + for i, item := range list { + if i > 0 { + buf = append(buf, ',') + } + buf = marshalBlobAndProofV2(buf, item) + } + buf = append(buf, ']') + return buf, nil +} diff --git a/beacon/engine/types.go b/beacon/engine/types.go index a312fee88a..92cf3900ae 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -152,11 +152,19 @@ type BlobAndProofV1 struct { Proof hexutil.Bytes `json:"proof"` } +// BlobAndProofListV1 is a list of BlobAndProofV1 with a hand-rolled JSON marshaler +// that avoids the overhead of encoding/json for large blob payloads. +type BlobAndProofListV1 []*BlobAndProofV1 + type BlobAndProofV2 struct { Blob hexutil.Bytes `json:"blob"` CellProofs []hexutil.Bytes `json:"proofs"` // proofs MUST contain exactly CELLS_PER_EXT_BLOB cell proofs. } +// BlobAndProofListV2 is a list of BlobAndProofV2 with a hand-rolled JSON marshaler +// 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 diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 8a4aced04b..be19bd5de4 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -535,7 +535,7 @@ func (api *ConsensusAPI) getPayload(payloadID engine.PayloadID, full bool, versi // // Client software MAY return an array of all null entries if syncing or otherwise // unable to serve blob pool data. -func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProofV1, error) { +func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) (engine.BlobAndProofListV1, error) { // Reject the request if Osaka has been activated. // follow https://github.com/ethereum/execution-apis/blob/main/src/engine/osaka.md#cancun-api head := api.eth.BlockChain().CurrentHeader() @@ -549,7 +549,7 @@ func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProo if err != nil { return nil, engine.InvalidParams.With(err) } - res := make([]*engine.BlobAndProofV1, len(hashes)) + res := make(engine.BlobAndProofListV1, len(hashes)) for i := 0; i < len(blobs); i++ { // Skip the non-existing blob if blobs[i] == nil { @@ -588,7 +588,7 @@ func (api *ConsensusAPI) GetBlobsV1(hashes []common.Hash) ([]*engine.BlobAndProo // // Client software MUST return null if syncing or otherwise unable to serve // blob pool data. -func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) ([]*engine.BlobAndProofV2, error) { +func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) (engine.BlobAndProofListV2, error) { head := api.eth.BlockChain().CurrentHeader() if api.config().LatestFork(head.Time) < forks.Osaka { return nil, nil @@ -599,7 +599,7 @@ func (api *ConsensusAPI) GetBlobsV2(hashes []common.Hash) ([]*engine.BlobAndProo // GetBlobsV3 returns a set of blobs from the transaction pool. Same as // GetBlobsV2, except will return partial responses in case there is a missing // blob. -func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) ([]*engine.BlobAndProofV2, error) { +func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) (engine.BlobAndProofListV2, error) { head := api.eth.BlockChain().CurrentHeader() if api.config().LatestFork(head.Time) < forks.Osaka { return nil, nil @@ -609,7 +609,7 @@ func (api *ConsensusAPI) GetBlobsV3(hashes []common.Hash) ([]*engine.BlobAndProo // getBlobs returns all available blobs. In v2, partial responses are not allowed, // while v3 supports partial responses. -func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.BlobAndProofV2, error) { +func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) (engine.BlobAndProofListV2, error) { if len(hashes) > 128 { return nil, engine.TooLargeRequest.With(fmt.Errorf("requested blob count too large: %v", len(hashes))) } @@ -629,7 +629,7 @@ func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.Blob return nil, engine.InvalidParams.With(err) } // Validate the blobs from the pool and assemble the response - res := make([]*engine.BlobAndProofV2, len(hashes)) + res := make(engine.BlobAndProofListV2, len(hashes)) for i := range blobs { // The blob has been evicted since the last AvailableBlobs call. // Return null if partial response is not allowed. diff --git a/eth/catalyst/api_benchmark_test.go b/eth/catalyst/api_benchmark_test.go index 368acc8a3f..3dc3b011de 100644 --- a/eth/catalyst/api_benchmark_test.go +++ b/eth/catalyst/api_benchmark_test.go @@ -691,3 +691,49 @@ func BenchmarkGetPayloadV5RPCServerOnly(b *testing.B) { b.StopTimer() b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") } + +// BenchmarkGetBlobsV3RPCServerOnly benchmarks only the EL server-side cost of +// engine_getBlobsV3: method dispatch, JSON serialization, and wire encoding. +// Client-side decoding is excluded by writing to io.Discard. +func BenchmarkGetBlobsV3RPCServerOnly(b *testing.B) { + blobCount := 72 + env := newBenchmarkBlobEnv(b, blobCount, 1, forkOsaka) + defer env.Close() + + // Register the engine API on the running node's in-process RPC server. + rpcServer, err := env.node.RPCHandler() + if err != nil { + b.Fatalf("RPCHandler failed: %v", err) + } + rpcServer.RegisterName("engine", env.api) + + // Verify the blobs are available via the direct API first. + result, err := env.api.GetBlobsV3(env.vhashes) + if err != nil { + b.Fatalf("GetBlobsV3 failed: %v", err) + } + if len(result) != blobCount { + b.Fatalf("expected %d blobs, got %d", blobCount, len(result)) + } + b.Logf("blob count: %d", blobCount) + + // Build the JSON-RPC request bytes once. + // Format the versioned hashes as a JSON array of hex strings. + var hashStrs []string + for _, h := range env.vhashes { + hashStrs = append(hashStrs, fmt.Sprintf(`"%s"`, h.Hex())) + } + reqJSON := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_getBlobsV3","params":[[%s]]}`, strings.Join(hashStrs, ",")) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + conn := discardConn{ + Reader: strings.NewReader(reqJSON), + Writer: io.Discard, + } + codec := rpc.NewCodec(conn) + rpcServer.ServeCodec(codec, 0) + } + b.StopTimer() + b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") +} diff --git a/eth/catalyst/api_test.go b/eth/catalyst/api_test.go index 1f38c4dd8a..65d78d84ee 100644 --- a/eth/catalyst/api_test.go +++ b/eth/catalyst/api_test.go @@ -1961,7 +1961,7 @@ func TestGetBlobsV1(t *testing.T) { // Fill the request for retrieving blobs var ( vhashes []common.Hash - expect []*engine.BlobAndProofV1 + expect engine.BlobAndProofListV1 ) // fill missing blob at the beginning if suite.fillRandom { @@ -2072,13 +2072,13 @@ func BenchmarkGetBlobsV2(b *testing.B) { } } -type getBlobsFn func(hashes []common.Hash) ([]*engine.BlobAndProofV2, error) +type getBlobsFn func(hashes []common.Hash) (engine.BlobAndProofListV2, error) func runGetBlobs(t testing.TB, getBlobs getBlobsFn, start, limit int, fillRandom bool, expectPartialResponse bool, name string) { // Fill the request for retrieving blobs var ( vhashes []common.Hash - expect []*engine.BlobAndProofV2 + expect engine.BlobAndProofListV2 ) for j := start; j < limit; j++ { vhashes = append(vhashes, testBlobVHashes[j])