Skip to content

Commit feee622

Browse files
authored
feat: migrate to beacon blobs endpoint for Fusaka (#49)
Implements support for the new /eth/v1/beacon/blobs/{block_id} endpoint as an automatic fallback from the deprecated blob_sidecars endpoint when it fails. Key changes: Archiver: Prefer blob_sidecars endpoint, fall back to blobs endpoint on failure and compute KZG commitments/proofs from blob data API: Add /eth/v1/beacon/blobs/{id} endpoint with versioned_hashes filtering support using kzg4844.CalcBlobHashV1 Test data: Generate valid BLS12-381 field elements by reducing random data modulo the field modulus to ensure KZG operations succeed Test helpers: Consolidate blob generation into NewBlobSidecars with proper header support for consistent test comparisons Even when the blobs endpoint is used, the archiver stores complete blob sidecars with derived KZG commitments and proofs, maintaining backward compatibility with existing storage format. NOTE: This code relies on attestantio/go-eth2-client#273, which has not yet been merged. An unreleased branch is used for this dependency for now.
1 parent f82be68 commit feee622

File tree

10 files changed

+532
-88
lines changed

10 files changed

+532
-88
lines changed

api/service/api.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service
22

33
import (
44
"context"
5+
"crypto/sha256"
56
"encoding/json"
67
"errors"
78
"fmt"
@@ -13,13 +14,15 @@ import (
1314

1415
client "github.com/attestantio/go-eth2-client"
1516
"github.com/attestantio/go-eth2-client/api"
17+
v1 "github.com/attestantio/go-eth2-client/api/v1"
1618
"github.com/attestantio/go-eth2-client/spec/deneb"
1719
m "github.com/base/blob-archiver/api/metrics"
1820
"github.com/base/blob-archiver/api/version"
1921
"github.com/base/blob-archiver/common/storage"
2022
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
2123
"github.com/ethereum/go-ethereum/common"
2224
"github.com/ethereum/go-ethereum/common/hexutil"
25+
"github.com/ethereum/go-ethereum/crypto/kzg4844"
2326
"github.com/ethereum/go-ethereum/log"
2427
"github.com/go-chi/chi/v5"
2528
"github.com/go-chi/chi/v5/middleware"
@@ -107,6 +110,7 @@ func NewAPI(dataStoreClient storage.DataStoreReader, beaconClient client.BeaconB
107110
})
108111

109112
r.Get("/eth/v1/beacon/blob_sidecars/{id}", result.blobSidecarHandler)
113+
r.Get("/eth/v1/beacon/blobs/{id}", result.blobsHandler)
110114
r.Get("/eth/v1/node/version", result.versionHandler)
111115

112116
return result
@@ -264,3 +268,112 @@ func filterBlobs(blobs []*deneb.BlobSidecar, _indices []string) ([]*deneb.BlobSi
264268

265269
return filteredBlobs, nil
266270
}
271+
272+
// filterBlobsByVersionedHashes filters sidecars by versioned hashes query parameter.
273+
// Returns the filtered sidecars in the order they were requested, or all sidecars if no hashes provided.
274+
func filterBlobsByVersionedHashes(sidecars []*deneb.BlobSidecar, _versionedHashes []string) ([]*deneb.BlobSidecar, *httpError) {
275+
var versionedHashes []string
276+
if len(_versionedHashes) == 0 {
277+
return sidecars, nil
278+
} else if len(_versionedHashes) == 1 {
279+
versionedHashes = strings.Split(_versionedHashes[0], ",")
280+
} else {
281+
versionedHashes = _versionedHashes
282+
}
283+
284+
// Build map of commitment hash -> sidecar for quick lookup
285+
// CalcBlobHashV1 requires a sha256 hasher instance
286+
hasher := sha256.New()
287+
hashToSidecar := make(map[[32]byte]*deneb.BlobSidecar)
288+
for _, sidecar := range sidecars {
289+
hasher.Reset()
290+
commitment := kzg4844.Commitment(sidecar.KZGCommitment)
291+
vh := kzg4844.CalcBlobHashV1(hasher, &commitment)
292+
hashToSidecar[vh] = sidecar
293+
}
294+
295+
// Return sidecars in the order of requested hashes
296+
filteredBlobs := make([]*deneb.BlobSidecar, 0, len(versionedHashes))
297+
for _, hashStr := range versionedHashes {
298+
hash := common.HexToHash(hashStr)
299+
var versionedHash [32]byte
300+
copy(versionedHash[:], hash[:])
301+
302+
if sidecar, ok := hashToSidecar[versionedHash]; ok {
303+
filteredBlobs = append(filteredBlobs, sidecar)
304+
}
305+
}
306+
307+
return filteredBlobs, nil
308+
}
309+
310+
// sidecarsToBlobs converts blob sidecars to a Blobs response by extracting only the blob data
311+
func sidecarsToBlobs(sidecars []*deneb.BlobSidecar) v1.Blobs {
312+
blobs := make(v1.Blobs, len(sidecars))
313+
for i, sidecar := range sidecars {
314+
blobs[i] = &sidecar.Blob
315+
}
316+
return blobs
317+
}
318+
319+
// blobsHandler implements the /eth/v1/beacon/blobs/{id} endpoint, using the underlying DataStoreReader
320+
// to fetch blobs instead of the beacon node. This endpoint serves blobs without KZG proofs.
321+
// Filtering by versioned_hashes query parameter is supported (per EIP-4844).
322+
func (a *API) blobsHandler(w http.ResponseWriter, r *http.Request) {
323+
param := chi.URLParam(r, "id")
324+
beaconBlockHash, err := a.toBeaconBlockHash(param)
325+
if err != nil {
326+
err.write(w)
327+
return
328+
}
329+
330+
result, storageErr := a.dataStoreClient.ReadBlob(r.Context(), beaconBlockHash)
331+
if storageErr != nil {
332+
if errors.Is(storageErr, storage.ErrNotFound) {
333+
errUnknownBlock.write(w)
334+
} else {
335+
a.logger.Info("unexpected error fetching blobs", "err", storageErr, "beaconBlockHash", beaconBlockHash.String(), "param", param)
336+
errServerError.write(w)
337+
}
338+
return
339+
}
340+
341+
sidecars := result.BlobSidecars.Data
342+
343+
// Filter by versioned_hashes query parameter (not indices)
344+
filteredSidecars, err := filterBlobsByVersionedHashes(sidecars, r.URL.Query()["versioned_hashes"])
345+
if err != nil {
346+
err.write(w)
347+
return
348+
}
349+
350+
// Convert sidecars to blobs
351+
blobs := sidecarsToBlobs(filteredSidecars)
352+
responseType := r.Header.Get("Accept")
353+
354+
if responseType == sszAcceptType {
355+
w.Header().Set("Content-Type", sszAcceptType)
356+
res, err := blobs.MarshalSSZ()
357+
if err != nil {
358+
a.logger.Error("unable to marshal blobs to SSZ", "err", err)
359+
errServerError.write(w)
360+
return
361+
}
362+
363+
_, err = w.Write(res)
364+
365+
if err != nil {
366+
a.logger.Error("unable to write ssz response", "err", err)
367+
errServerError.write(w)
368+
return
369+
}
370+
} else {
371+
w.Header().Set("Content-Type", jsonAcceptType)
372+
err := json.NewEncoder(w).Encode(blobs)
373+
if err != nil {
374+
a.logger.Error("unable to encode blobs to JSON", "err", err)
375+
errServerError.write(w)
376+
return
377+
}
378+
}
379+
}

api/service/api_test.go

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package service
33
import (
44
"compress/gzip"
55
"context"
6+
"crypto/sha256"
67
"encoding/json"
78
"fmt"
89
"io"
@@ -21,6 +22,7 @@ import (
2122
"github.com/ethereum-optimism/optimism/op-service/eth"
2223
"github.com/ethereum-optimism/optimism/op-service/testlog"
2324
"github.com/ethereum/go-ethereum/common"
25+
"github.com/ethereum/go-ethereum/crypto/kzg4844"
2426
"github.com/ethereum/go-ethereum/log"
2527
"github.com/stretchr/testify/require"
2628
)
@@ -80,7 +82,7 @@ func TestAPIService(t *testing.T) {
8082
BeaconBlockHash: rootOne,
8183
},
8284
BlobSidecars: storage.BlobSidecars{
83-
Data: blobtest.NewBlobSidecars(t, 2),
85+
Data: blobtest.NewBlobSidecars(t, 2, nil),
8486
},
8587
}
8688

@@ -89,7 +91,7 @@ func TestAPIService(t *testing.T) {
8991
BeaconBlockHash: rootTwo,
9092
},
9193
BlobSidecars: storage.BlobSidecars{
92-
Data: blobtest.NewBlobSidecars(t, 2),
94+
Data: blobtest.NewBlobSidecars(t, 2, nil),
9395
},
9496
}
9597

@@ -99,7 +101,7 @@ func TestAPIService(t *testing.T) {
99101
BeaconBlockHash: rootThree,
100102
},
101103
BlobSidecars: storage.BlobSidecars{
102-
Data: blobtest.NewBlobSidecars(t, 8), // More than 6 blobs
104+
Data: blobtest.NewBlobSidecars(t, 8, nil), // More than 6 blobs
103105
},
104106
}
105107

@@ -364,3 +366,130 @@ func TestHealthHandler(t *testing.T) {
364366

365367
require.Equal(t, 200, response.Code)
366368
}
369+
370+
func TestBlobsHandlerJSON(t *testing.T) {
371+
a, fs, _, cleanup := setup(t)
372+
defer cleanup()
373+
374+
// Pre-populate storage with blob sidecars
375+
testBlobs := blobtest.NewBlobSidecars(t, 3, nil)
376+
data := storage.BlobData{
377+
Header: storage.Header{
378+
BeaconBlockHash: blobtest.Five,
379+
},
380+
BlobSidecars: storage.BlobSidecars{
381+
Data: testBlobs,
382+
},
383+
}
384+
err := fs.WriteBlob(context.Background(), data)
385+
require.NoError(t, err)
386+
387+
// Request blobs endpoint with JSON encoding
388+
request := httptest.NewRequest("GET", "/eth/v1/beacon/blobs/"+blobtest.Five.String(), nil)
389+
request.Header.Set("Accept", "application/json")
390+
response := httptest.NewRecorder()
391+
392+
a.router.ServeHTTP(response, request)
393+
394+
require.Equal(t, 200, response.Code)
395+
require.Equal(t, "application/json", response.Header().Get("Content-Type"))
396+
397+
var blobs v1.Blobs
398+
err = json.Unmarshal(response.Body.Bytes(), &blobs)
399+
require.NoError(t, err)
400+
require.Equal(t, len(testBlobs), len(blobs))
401+
402+
// Verify blob data matches
403+
for i, blob := range blobs {
404+
require.Equal(t, testBlobs[i].Blob, *blob)
405+
}
406+
}
407+
408+
func TestBlobsHandlerSSZ(t *testing.T) {
409+
a, fs, _, cleanup := setup(t)
410+
defer cleanup()
411+
412+
// Pre-populate storage with blob sidecars
413+
testBlobs := blobtest.NewBlobSidecars(t, 2, nil)
414+
data := storage.BlobData{
415+
Header: storage.Header{
416+
BeaconBlockHash: blobtest.One,
417+
},
418+
BlobSidecars: storage.BlobSidecars{
419+
Data: testBlobs,
420+
},
421+
}
422+
err := fs.WriteBlob(context.Background(), data)
423+
require.NoError(t, err)
424+
425+
// Request blobs endpoint with SSZ encoding
426+
request := httptest.NewRequest("GET", "/eth/v1/beacon/blobs/"+blobtest.One.String(), nil)
427+
request.Header.Set("Accept", "application/octet-stream")
428+
response := httptest.NewRecorder()
429+
430+
a.router.ServeHTTP(response, request)
431+
432+
require.Equal(t, 200, response.Code)
433+
require.Equal(t, "application/octet-stream", response.Header().Get("Content-Type"))
434+
435+
// Unmarshal SSZ response
436+
blobs := v1.Blobs{}
437+
err = blobs.UnmarshalSSZ(response.Body.Bytes())
438+
require.NoError(t, err)
439+
require.Equal(t, len(testBlobs), len(blobs))
440+
}
441+
442+
func TestBlobsHandlerWithVersionedHashes(t *testing.T) {
443+
a, fs, _, cleanup := setup(t)
444+
defer cleanup()
445+
446+
// Pre-populate storage with blob sidecars
447+
testBlobs := blobtest.NewBlobSidecars(t, 5, nil)
448+
data := storage.BlobData{
449+
Header: storage.Header{
450+
BeaconBlockHash: blobtest.Four,
451+
},
452+
BlobSidecars: storage.BlobSidecars{
453+
Data: testBlobs,
454+
},
455+
}
456+
err := fs.WriteBlob(context.Background(), data)
457+
require.NoError(t, err)
458+
459+
// Compute versioned hashes from commitments
460+
hasher := sha256.New()
461+
commitment0 := kzg4844.Commitment(testBlobs[0].KZGCommitment)
462+
vh0 := kzg4844.CalcBlobHashV1(hasher, &commitment0)
463+
464+
hasher.Reset()
465+
commitment2 := kzg4844.Commitment(testBlobs[2].KZGCommitment)
466+
vh2 := kzg4844.CalcBlobHashV1(hasher, &commitment2)
467+
468+
// Request blobs endpoint with versioned_hashes filter
469+
request := httptest.NewRequest("GET", fmt.Sprintf("/eth/v1/beacon/blobs/%s?versioned_hashes=%s&versioned_hashes=%s", blobtest.Four.String(), common.Hash(vh0).Hex(), common.Hash(vh2).Hex()), nil)
470+
request.Header.Set("Accept", "application/json")
471+
response := httptest.NewRecorder()
472+
473+
a.router.ServeHTTP(response, request)
474+
475+
require.Equal(t, 200, response.Code)
476+
477+
var blobs v1.Blobs
478+
err = json.Unmarshal(response.Body.Bytes(), &blobs)
479+
require.NoError(t, err)
480+
require.Equal(t, 2, len(blobs))
481+
}
482+
483+
func TestBlobsHandlerNotFound(t *testing.T) {
484+
a, _, _, cleanup := setup(t)
485+
defer cleanup()
486+
487+
// Request blobs endpoint for non-existent block
488+
request := httptest.NewRequest("GET", "/eth/v1/beacon/blobs/"+blobtest.Seven.String(), nil)
489+
request.Header.Set("Accept", "application/json")
490+
response := httptest.NewRecorder()
491+
492+
a.router.ServeHTTP(response, request)
493+
494+
require.Equal(t, 404, response.Code)
495+
}

0 commit comments

Comments
 (0)